import gradio as gr
import subprocess, shutil, os, zipfile, datetime, sys, time, uuid, stat, re
from pathlib import Path
import base64


# =====================
# Version guard
# =====================
def _ensure_versions():
    import importlib, subprocess, sys

    def get_version(pkg):
        try:
            m = importlib.import_module(pkg)
            return getattr(m, "__version__", "0")
        except Exception:
            return "0"

    try:
        from packaging.version import Version
    except ImportError:
        # 安装packaging，确保下面版本比较能用
        subprocess.check_call([sys.executable, "-m", "pip", "install", "packaging"])
        from packaging.version import Version

    # 检查 huggingface_hub
    hub_ver = get_version("huggingface_hub")
    hv = Version(hub_ver)

    required_min = Version("0.24.0")
    required_max = Version("1.0.0")

    hub_ok = required_min <= hv < required_max

    if not hub_ok:
        print(f"[INFO] huggingface_hub=={hub_ver} not in range "
              f"[{required_min}, {required_max}), reinstalling...")
        subprocess.check_call([
            sys.executable, "-m", "pip", "install",
            "huggingface-hub==0.27.1",
            "transformers==4.48.0",
            "--force-reinstall", "--no-deps"
        ])
    else:
        print(f"[INFO] huggingface_hub version OK: {hub_ver}")

_ensure_versions()

# =====================
# Paths (read-only repo root; DO NOT write here)
# =====================
ROOT = Path(__file__).resolve().parent
RUNS_DIR = ROOT / "runs"              # all per-run workspaces live here
RUNS_DIR.mkdir(parents=True, exist_ok=True)

TIMEOUT_SECONDS = 1800  # 30 minutes
RETENTION_HOURS = 1    # auto-clean runs older than N hours
DEFAULT_RIGHT_LOGO_PATH = ROOT / "template" / "logos" / "right_logo.png"

# ---------------------
# Utils
# ---------------------
def _now_str():
    return datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')

def _write_logs(log_path: Path, logs):
    try:
        log_path.parent.mkdir(parents=True, exist_ok=True)
        with open(log_path, "w", encoding="utf-8") as f:
            f.write("\n".join(logs))
    except Exception:
        pass

def _find_ui_header_logos(root: Path):
    """Return list of Paths for UI header logos in order camel → tvg → waterloo.
    Prefer template/logos; fallback to assets folders; if none match, return up to 3 images.
    """
    preferred = root / "template" / "logos"
    # Desired visual order (left → right) with CAMEL at rightmost:
    names = ["tvg", "waterloo", "camel"]
    found = []
    # Try preferred dir first with explicit names
    try:
        if preferred.exists():
            allp = list(preferred.iterdir())
            for key in names:
                for p in allp:
                    if p.is_file() and p.suffix.lower() in {".png", ".jpg", ".jpeg", ".webp"} and key in p.name.lower():
                        found.append(p)
                        break
    except Exception:
        pass
    # If not all found, search broader locations
    if len(found) < 3:
        cand_dirs = [
            root / "assets",
            root / "assets" / "logos",
            root / "Paper2Poster" / "assets",
            root / "Paper2Poster" / "assets" / "logos",
            root / "paper2poster" / "assets",
            root / "paper2poster" / "assets" / "logos",
        ]
        imgs = []
        for d in cand_dirs:
            try:
                if d.exists():
                    for p in d.iterdir():
                        if p.is_file() and p.suffix.lower() in {".png", ".jpg", ".jpeg", ".webp"}:
                            imgs.append(p)
            except Exception:
                continue
        for key in names:
            if any(key in str(fp).lower() for fp in found):
                continue
            for p in imgs:
                if key in p.name.lower():
                    found.append(p)
                    break
        # Fallback: fill up to 3
        if not found and imgs:
            found = imgs[:3]
    return found

def _ui_header_logos_html():
    """Return an HTML div with base64-embedded logos to avoid broken /file routes.
    Logos are fixed at top-right, slightly inset, with larger spacing.
    """
    import base64
    logos = _find_ui_header_logos(ROOT)
    if not logos:
        return ""
    parts = []
    for p in logos:
        try:
            b = p.read_bytes()
            b64 = base64.b64encode(b).decode("utf-8")
            mime = "image/png" if p.suffix.lower() == ".png" else "image/jpeg"
            src = f"data:{mime};base64,{b64}"
            name = p.name.lower()
            href = None
            if "camel" in name:
                href = "https://www.camel-ai.org/"
            elif "tvg" in name or "torr" in name:
                href = "https://torrvision.com/index.html"
            elif "waterloo" in name:
                href = "https://uwaterloo.ca/"
            img = f"<img src='{src}' alt='{p.stem}' style='height:44px;width:auto;object-fit:contain;display:block;cursor:pointer'>"
            parts.append(f"<a href='{href}' target='_blank' rel='noopener'>{img}</a>" if href else img)
        except Exception:
            continue
    if not parts:
        return ""
    # Fixed-position header bar at top-right; moved further left and larger spacing
    imgs = "".join(parts)
    return (
        "<style>"
        "#p2p-logo-bar{position:fixed;top:12px;right:140px;display:flex;gap:24px;"
        "align-items:center;z-index:9999}"
        "#p2p-logo-bar a{display:block;line-height:0}"
        "@media (max-width: 768px){#p2p-logo-bar img{height:32px}}"
        "</style>"
        "<div id='p2p-logo-bar'>" + imgs + "</div>"
    )

def _default_conf_logo_path():
    """Pick a default conference logo to preview.
    Prefer Paper2Poster/assets/neurips.png, else template/logos/right_logo.png,
    else the first of detected header logos.
    """
    try:
        prefer_assets = ROOT / "Paper2Poster" / "assets" / "neurips.png"
        if prefer_assets.exists():
            return prefer_assets
        pref = ROOT / "template" / "logos" / "right_logo.png"
        if pref.exists():
            return pref
        logos = _find_ui_header_logos(ROOT)
        for p in logos:
            if p and p.exists():
                return p
    except Exception:
        pass
    return None

## Removed sanitizer per request: do not mutate user-generated TeX

def _on_rm_error(func, path, exc_info):
    # fix "PermissionError: [Errno 13] Permission denied" for readonly files
    os.chmod(path, stat.S_IWRITE)
    func(path)

def _copytree(src: Path, dst: Path, symlinks=True, ignore=None):
    if dst.exists():
        shutil.rmtree(dst, onerror=_on_rm_error)
    shutil.copytree(src, dst, symlinks=symlinks, ignore=ignore)

def _safe_copy(src: Path, dst: Path):
    dst.parent.mkdir(parents=True, exist_ok=True)
    shutil.copy2(src, dst)

def _cleanup_old_runs(max_age_hours=12):
    try:
        now = datetime.datetime.now().timestamp()
        for run_dir in RUNS_DIR.iterdir():
            try:
                if not run_dir.is_dir():
                    continue
                mtime = run_dir.stat().st_mtime
                age_h = (now - mtime) / 3600.0
                if age_h > max_age_hours:
                    shutil.rmtree(run_dir, onerror=_on_rm_error)
            except Exception:
                continue
    except Exception:
        pass

def _prepare_workspace(logs):
    """Create isolated per-run workspace and copy needed code/assets into it."""
    run_id = datetime.datetime.now().strftime("%Y%m%d-%H%M%S") + "-" + uuid.uuid4().hex[:8]
    work_dir = RUNS_DIR / run_id
    work_dir.mkdir(parents=True, exist_ok=True)

    # Per-run log & zip path
    log_path = work_dir / "run.log"
    zip_path = work_dir / "output.zip"

    logs.append(f"🧩 New workspace: {work_dir.relative_to(ROOT)} (run_id={run_id})")

    # Copy code/assets that do file IO so they are run-local (avoid shared writes)
    # Keep copies as cheap as possible (symlinks=True when supported)
    needed_dirs = ["posterbuilder", "Paper2Poster"]
    for d in needed_dirs:
        src = ROOT / d
        if src.exists():
            _copytree(src, work_dir / d, symlinks=True)
            logs.append(f"   ↪ copied {d}/ → runs/{run_id}/{d}/ (symlink where possible)")

    # template/ optional
    tmpl = ROOT / "template"
    if tmpl.exists():
        _copytree(tmpl, work_dir / "template", symlinks=True)
        logs.append("   ↪ copied template/")

    # pipeline.py must live inside workspace so that ROOT_DIR=work_dir
    _safe_copy(ROOT / "pipeline.py", work_dir / "pipeline.py")

    # Create standard IO dirs in workspace
    (work_dir / "input" / "pdf").mkdir(parents=True, exist_ok=True)
    (work_dir / "input" / "logo").mkdir(parents=True, exist_ok=True)
    (work_dir / "posterbuilder" / "latex_proj").mkdir(parents=True, exist_ok=True)

    return run_id, work_dir, log_path, zip_path

# ---------------------
# Helpers for new features (post-processing)
# ---------------------
def _parse_rgb(val):
    """Return (R, G, B) as ints in [0,255] from '#RRGGBB', 'rgb(...)', 'rgba(...)', 'r,g,b', [r,g,b], or (r,g,b)."""
    if val is None:
        return None

    import re

    def clamp255(x):
        try:
            return max(0, min(255, int(round(float(x)))))
        except Exception:
            return None

    s = str(val).strip()

    # list/tuple
    if isinstance(val, (list, tuple)) and len(val) >= 3:
        r, g, b = [clamp255(val[0]), clamp255(val[1]), clamp255(val[2])]
        if None not in (r, g, b):
            return (r, g, b)

    # hex: #RGB or #RRGGBB
    if s.startswith("#"):
        hx = s[1:].strip()
        if len(hx) == 3:
            hx = "".join(c*2 for c in hx)
        if len(hx) == 6 and re.fullmatch(r"[0-9A-Fa-f]{6}", hx):
            return tuple(int(hx[i:i+2], 16) for i in (0, 2, 4))

    # rgb/rgba(...)
    m = re.match(r"rgba?\(\s*([^)]+)\)", s, flags=re.IGNORECASE)
    if m:
        parts = [p.strip() for p in m.group(1).split(",")]
        if len(parts) >= 3:
            def to_int(p):
                if p.endswith("%"):
                    # percentage to 0-255
                    return clamp255(float(p[:-1]) * 255.0 / 100.0)
                return clamp255(p)
            r, g, b = to_int(parts[0]), to_int(parts[1]), to_int(parts[2])
            if None not in (r, g, b):
                return (r, g, b)

    # 'r,g,b'
    if "," in s:
        parts = [p.strip() for p in s.split(",")]
        if len(parts) >= 3:
            def to_int(p):
                if p.endswith("%"):
                    return clamp255(float(p[:-1]) * 255.0 / 100.0)
                return clamp255(p)
            r, g, b = to_int(parts[0]), to_int(parts[1]), to_int(parts[2])
            if None not in (r, g, b):
                return (r, g, b)

    return None


def _apply_meeting_logo(OUTPUT_DIR: Path, meeting_logo_file, logs):
    """Replace output/poster_latex_proj/logos/right_logo.png if meeting_logo_file provided."""
    if not meeting_logo_file:
        return False

def _ensure_right_logo_default(OUTPUT_DIR: Path, logs):
    """If no right_logo.png exists in output poster project, copy a default NeurIPS logo.
    Looks for Paper2Poster/assets/neurips.png first, else template/logos/right_logo.png.
    Returns True if a logo was written.
    """
    try:
        logos_dir = OUTPUT_DIR / "poster_latex_proj" / "logos"
        target = logos_dir / "right_logo.png"
        if target.exists():
            return False
        # Preferred default
        prefer_assets = ROOT / "Paper2Poster" / "assets" / "neurips.png"
        fallback_tpl = ROOT / "template" / "logos" / "right_logo.png"
        src = None
        if prefer_assets.exists():
            src = prefer_assets
        elif fallback_tpl.exists():
            src = fallback_tpl
        if src is None:
            logs.append("⚠️ No default right_logo source found (neurips.png or template right_logo.png).")
            return False
        logos_dir.mkdir(parents=True, exist_ok=True)
        shutil.copy2(src, target)
        logs.append(f"🏷️ Applied default conference logo → {target.relative_to(OUTPUT_DIR)}")
        return True
    except Exception as e:
        logs.append(f"⚠️ Failed to apply default right_logo: {e}")
        return False

    logos_dir = OUTPUT_DIR / "poster_latex_proj" / "logos"
    target = logos_dir / "right_logo.png"
    try:
        logos_dir.mkdir(parents=True, exist_ok=True)
        # Try to convert to PNG for safety
        try:
            from PIL import Image
            img = Image.open(meeting_logo_file.name)
            # preserve alpha if available
            if img.mode not in ("RGB", "RGBA"):
                img = img.convert("RGBA")
            img.save(target, format="PNG")
            logs.append(f"🖼️ Meeting logo converted to PNG and saved → {target.relative_to(OUTPUT_DIR)}")
        except Exception as e:
            # Fallback: raw copy with .png name
            shutil.copy(meeting_logo_file.name, target)
            logs.append(f"🖼️ Meeting logo copied (no conversion) → {target.relative_to(OUTPUT_DIR)} (note: ensure it's a valid PNG).")
        return True
    except Exception as e:
        logs.append(f"⚠️ Failed to apply meeting logo: {e}")
        return False

def _apply_theme_rgb(OUTPUT_DIR: Path, rgb_tuple, logs):
    if not rgb_tuple:
        return False

    tex_path = OUTPUT_DIR / "poster_latex_proj" / "poster_output.tex"
    if not tex_path.exists():
        logs.append(f"⚠️ Theme RGB skipped: {tex_path.relative_to(OUTPUT_DIR)} not found.")
        return False

    try:
        content = tex_path.read_text(encoding="utf-8")
        r, g, b = rgb_tuple
        name_pattern = r"(?:nipspurple|neuripspurple|themecolor)"

        rgb_pat = rf"(\\definecolor\{{{name_pattern}\}}\{{RGB\}}\{{)\s*\d+\s*,\s*\d+\s*,\s*\d+\s*(\}})"

        def repl_rgb(m):
            return f"{m.group(1)}{r},{g},{b}{m.group(2)}"

        new_content, n = re.subn(rgb_pat, repl_rgb, content, flags=re.MULTILINE)

        if n == 0:
            hexval = f"{r:02X}{g:02X}{b:02X}"
            html_pat = rf"(\\definecolor\{{{name_pattern}\}}\{{HTML\}}\{{)[0-9A-Fa-f]{{6}}(\}})"

            def repl_html(m):
                return f"{m.group(1)}{hexval}{m.group(2)}"

            new_content, n = re.subn(html_pat, repl_html, content, flags=re.MULTILINE)

        if n > 0:
            tex_path.write_text(new_content, encoding="utf-8")
            logs.append(f"🎨 Theme color updated to RGB {{{r},{g},{b}}}")
            return True
        else:
            logs.append("ℹ️ No \\definecolor target found.")
            return False

    except Exception as e:
        logs.append(f"⚠️ Failed to update theme RGB: {e}")
        return False



def _apply_left_logo(OUTPUT_DIR: Path, logo_files, logs):
    """
    Use the first institutional logo uploaded by the user:
    - Copy it into output/poster_latex_proj/logos/ as left_logo.<ext>
    - Replace 'logos/left_logo.png' in poster_output.tex with the proper file extension
    Does NOT convert formats. Simply renames and rewrites the tex reference.
    """
    if not logo_files:
        logs.append("ℹ️ No institutional logo uploaded.")
        return False

    if isinstance(logo_files, (list, tuple)) and len(logo_files) > 1:
        logs.append("Multiple institutional logos uploaded.")
        return False

    # Single file case
    f = logo_files[0] if isinstance(logo_files, (list, tuple)) else logo_files
    if not f:
        logs.append("ℹ️ No institutional logo uploaded.")
        return False

    ext = Path(f.name).suffix or ".png"  # fallback to .png if no extension
    logos_dir = OUTPUT_DIR / "poster_latex_proj" / "logos"
    tex_path = OUTPUT_DIR / "poster_latex_proj" / "poster_output.tex"

    try:
        logos_dir.mkdir(parents=True, exist_ok=True)
        dst = logos_dir / f"left_logo{ext}"
        shutil.copy(f.name, dst)
        logs.append(f"🏷️ Institutional logo copied to: {dst.relative_to(OUTPUT_DIR)}")
    except Exception as e:
        logs.append(f"⚠️ Failed to copy institutional logo: {e}")
        return False

    if not tex_path.exists():
        logs.append("⚠️ poster_output.tex not found, cannot replace left_logo path.")
        return False

    try:
        text = tex_path.read_text(encoding="utf-8")
        old = "logos/left_logo.png"
        new = f"logos/left_logo{ext}"

        if old in text:
            tex_path.write_text(text.replace(old, new), encoding="utf-8")
            logs.append(f"🛠️ Replaced left_logo.png → left_logo{ext} in poster_output.tex")
            return True

        # Fallback (covers weird spacing or macro variations)
        import re
        pattern = r"(logos/left_logo)\.png"
        new_text, n = re.subn(pattern, r"\1" + ext, text)

        if n > 0:
            tex_path.write_text(new_text, encoding="utf-8")
            logs.append(f"🛠️ Replaced left_logo.png → left_logo{ext} (regex fallback)")
            return True

        logs.append("ℹ️ No left_logo.png reference found in poster_output.tex.")
        return False

    except Exception as e:
        logs.append(f"⚠️ Failed to modify poster_output.tex: {e}")
        return False

def render_overleaf_button(overleaf_b64):
    if not overleaf_b64:
        return ""
    
    html = f"""
    <form action="https://www.overleaf.com/docs" method="post" target="_blank">
      <input type="hidden" name="snip_uri" value="data:application/zip;base64,{overleaf_b64}">
      <input type="hidden" name="engine" value="xelatex">
      <button style="
        background:#4CAF50;color:white;padding:8px 14px;
        border:none;border-radius:6px;cursor:pointer; margin-top:8px;
      ">
        🚀 Open in Overleaf
      </button>
    </form>
    """
    return html

def _get_tectonic_bin(logs):
    """Return a usable path to the tectonic binary. Try PATH/common paths; if not found, download to runs/_bin."""
    import shutil as _sh, tarfile, urllib.request, os
    # 1) existing in PATH or common dirs
    cands = [
        "tectonic",
        str(Path("/usr/local/bin/tectonic")),
        str(Path("/usr/bin/tectonic")),
        os.path.expanduser("~/.local/bin/tectonic"),
        str((RUNS_DIR / "_bin" / "tectonic").resolve()),
    ]
    for c in cands:
        if _sh.which(c) or Path(c).exists():
            return c
    # 2) download to runs/_bin
    try:
        url = "https://github.com/tectonic-typesetting/tectonic/releases/download/tectonic%400.15.0/tectonic-0.15.0-x86_64-unknown-linux-gnu.tar.gz"
        bin_dir = RUNS_DIR / "_bin"
        bin_dir.mkdir(parents=True, exist_ok=True)
        tgz = bin_dir / "tectonic.tar.gz"
        logs.append("⬇️ Downloading tectonic binary …")
        with urllib.request.urlopen(url, timeout=60) as resp, open(tgz, "wb") as out:
            out.write(resp.read())
        with tarfile.open(tgz, "r:gz") as tf:
            tf.extractall(path=bin_dir)
        # find binary
        tbin = None
        for p in bin_dir.rglob("tectonic"):
            if p.is_file() and os.access(p, os.X_OK):
                tbin = p
                break
        if not tbin:
            # make executable if needed
            for p in bin_dir.rglob("tectonic"):
                try:
                    p.chmod(0o755)
                    tbin = p
                    break
                except Exception:
                    continue
        if tbin:
            logs.append(f"✅ Tectonic ready at {tbin}")
            return str(tbin)
        logs.append("⚠️ Tectonic binary not found after download.")
    except Exception as e:
        logs.append(f"⚠️ Tectonic download failed: {e}")
    return None

def _compile_poster_pdf(OUTPUT_DIR: Path, logs):
    """
    Compile output/poster_latex_proj/poster_output.tex into a PDF using an
    available TeX engine. Prefer 'tectonic', then 'lualatex', then 'xelatex',
    then 'latexmk'. Returns Path to the PDF or None.
    """
    try:
        proj_dir = OUTPUT_DIR / "poster_latex_proj"
        tex_path = proj_dir / "poster_output.tex"
        if not tex_path.exists():
            logs.append(f"⚠️ LaTeX source not found: {tex_path.relative_to(OUTPUT_DIR)}")
            return None

        # Clean old PDFs
        for cand in (proj_dir / "poster_output.pdf", proj_dir / "poster.pdf"):
            try:
                if cand.exists():
                    cand.unlink()
            except Exception:
                pass

        import shutil as _sh
        import subprocess as _sp

        def _has(bin_name):
            return _sh.which(bin_name) is not None

        # Most-tolerant: prefer latexmk with XeLaTeX and force (-f), then XeLaTeX, then LuaLaTeX
        pretex = r"\nonstopmode\scrollmode\makeatletter\let\pgf@error\pgf@warning\let\GenericError\GenericWarning\let\PackageError\PackageWarning\makeatother"
        if _has("latexmk"):
            cmd = [
                "latexmk", "-pdf", "-pdflatex=xelatex", "-f",
                "-interaction=nonstopmode", "-file-line-error",
                f"-pretex={pretex}",
                tex_path.name,
            ]
            logs.append("▶ Compiling with latexmk (-pdf -pdflatex=xelatex -f, pretex demote errors) …")
        elif _has("xelatex"):
            # Inject pretex macros via direct input to engine (no file mutation)
            injected = pretex + f"\\input{{{tex_path.name}}}"
            cmd = ["xelatex", "-interaction=nonstopmode", "-file-line-error", injected]
            logs.append("▶ Compiling with xelatex (pretex injected) …")
        elif _has("lualatex"):
            injected = pretex + f"\\input{{{tex_path.name}}}"
            cmd = ["lualatex", "-interaction=nonstopmode", "-file-line-error", injected]
            logs.append("▶ Compiling with lualatex (pretex injected) …")
        else:
            logs.append("⚠️ No TeX engine found (latexmk/xelatex/lualatex). Skipping PDF compile.")
            return None

        import os as _os
        _env = _os.environ.copy()
        # Ensure TeX can find local theme/fonts across project tree
        texinputs = _env.get("TEXINPUTS", "")
        search = _os.pathsep.join([
            str(proj_dir), str(proj_dir) + "//",
            str(proj_dir.parent), str(proj_dir.parent) + "//",
        ])
        _env["TEXINPUTS"] = search + _os.pathsep + texinputs
        proc = _sp.run(cmd, cwd=str(proj_dir), stdout=_sp.PIPE, stderr=_sp.STDOUT, text=True, env=_env)
        if proc.stdout:
            logs.append(proc.stdout[-4000:])

        # Accept PDF even if return code is non-zero (be tolerant)
        for out_name in ("poster_output.pdf", "poster.pdf", tex_path.stem + ".pdf"):
            out_path = proj_dir / out_name
            if out_path.exists():
                if proc.returncode != 0:
                    logs.append(f"⚠️ Compile returned code {proc.returncode}, but PDF exists; using it.")
                else:
                    logs.append(f"✅ PDF generated → {out_path.relative_to(OUTPUT_DIR)}")
                return out_path

        if proc.returncode != 0:
            logs.append(f"❌ PDF compile failed with code {proc.returncode} and no PDF produced.")
            return None

        logs.append("⚠️ PDF not found after compile.")
        return None
    except Exception as e:
        logs.append(f"⚠️ PDF compile error: {e}")
        return None

def _pdf_to_iframe_html(pdf_path: Path, width="100%", height="900px") -> str:
    try:
        b = pdf_path.read_bytes()
        b64 = base64.b64encode(b).decode("utf-8")
        return (
            f"<div style='border:1px solid #ddd;border-radius:8px;overflow:hidden'>"
            f"<embed type='application/pdf' width='{width}' height='{height}' src='data:application/pdf;base64,{b64}'></embed>"
            f"</div>"
        )
    except Exception:
        return ""

def _pdf_to_iframe_file(pdf_path: Path, width="100%", height="900px") -> str:
    try:
        from urllib.parse import quote
        p = str(pdf_path)
        src = f"/file={quote(p)}"
        return (
            f"<div style='border:1px solid #ddd;border-radius:8px;overflow:hidden'>"
            f"<iframe src='{src}' width='{width}' height='{height}' style='border:0'></iframe>"
            f"</div>"
        )
    except Exception:
        return ""

def _pdf_to_image_first_page(pdf_path: Path, out_dir: Path, logs) -> Path | None:
    try:
        out_dir.mkdir(parents=True, exist_ok=True)
        out_path = out_dir / "poster_preview.png"
        # Try PyMuPDF
        try:
            import fitz  # PyMuPDF
            doc = fitz.open(str(pdf_path))
            if doc.page_count == 0:
                return None
            page = doc.load_page(0)
            mat = fitz.Matrix(2, 2)
            pix = page.get_pixmap(matrix=mat, alpha=False)
            pix.save(str(out_path))
            return out_path if out_path.exists() else None
        except Exception as e:
            logs.append(f"⚠️ PyMuPDF render failed: {e}")
        # Fallback: pypdfium2
        try:
            import pypdfium2 as pdfium
            pdf = pdfium.PdfDocument(str(pdf_path))
            if len(pdf) == 0:
                return None
            page = pdf[0]
            bitmap = page.render(scale=2).to_pil()
            bitmap.save(out_path)
            return out_path if out_path.exists() else None
        except Exception as e:
            logs.append(f"⚠️ pypdfium2 render failed: {e}")
    except Exception as e:
        logs.append(f"⚠️ image preview failed: {e}")
    return None

def preview_image_from_pdf(pdf_file):
    try:
        path = pdf_file
        if hasattr(pdf_file, 'name'):
            path = pdf_file.name
        if not path:
            return None
        p = Path(path)
        logs = []
        img = _pdf_to_image_first_page(p, p.parent, logs)
        return str(img) if img and img.exists() else None
    except Exception:
        return None

def _compile_tex_to_pdf(tex_path: Path, logs):
    """Generic TeX compile helper for a .tex file. Returns Path to PDF or None."""
    try:
        proj_dir = tex_path.parent
        import shutil as _sh, subprocess as _sp
        def _has(bin_name):
            return _sh.which(bin_name) is not None
        # Most-tolerant: latexmk first with pretex demoting errors; fallbacks inject pretex, too
        pretex = r"\nonstopmode\scrollmode\makeatletter\let\pgf@error\pgf@warning\let\GenericError\GenericWarning\let\PackageError\PackageWarning\makeatother"
        if _has("latexmk"):
            cmd = [
                "latexmk", "-pdf", "-pdflatex=xelatex", "-f",
                "-interaction=nonstopmode", "-file-line-error",
                f"-pretex={pretex}",
                tex_path.name,
            ]
            logs.append("▶ Compiling with latexmk (-pdf -pdflatex=xelatex -f, pretex demote errors) …")
        elif _has("xelatex"):
            injected = pretex + f"\\input{{{tex_path.name}}}"
            cmd = ["xelatex", "-interaction=nonstopmode", "-file-line-error", injected]
            logs.append("▶ Compiling with xelatex (pretex injected) …")
        elif _has("lualatex"):
            injected = pretex + f"\\input{{{tex_path.name}}}"
            cmd = ["lualatex", "-interaction=nonstopmode", "-file-line-error", injected]
            logs.append("▶ Compiling with lualatex (pretex injected) …")
        else:
            logs.append("⚠️ No TeX engine found (xelatex/lualatex/latexmk).")
            return None
        import os as _os
        _env = _os.environ.copy()
        texinputs = _env.get("TEXINPUTS", "")
        search = _os.pathsep.join([
            str(proj_dir), str(proj_dir) + "//",
            str(proj_dir.parent), str(proj_dir.parent) + "//",
        ])
        _env["TEXINPUTS"] = search + _os.pathsep + texinputs
        proc = _sp.run(cmd, cwd=str(proj_dir), stdout=_sp.PIPE, stderr=_sp.STDOUT, text=True, env=_env)
        if proc.stdout:
            logs.append(proc.stdout[-4000:])
        guess = proj_dir / (tex_path.stem + ".pdf")
        if proc.returncode != 0:
            # Be tolerant: if a PDF was produced despite errors, use it.
            if guess.exists():
                logs.append(f"⚠️ Compile returned code {proc.returncode}, but PDF exists; using it.")
                return guess
            logs.append(f"❌ PDF compile failed with code {proc.returncode} and no PDF produced.")
            return None
        return guess if guess.exists() else None
    except Exception as e:
        logs.append(f"⚠️ PDF compile error: {e}")
        return None

def _ensure_left_logo_or_disable(OUTPUT_DIR: Path, logs):
    """If no left_logo.* exists in logos/, comment out \logoleft line in poster_output.tex."""
    tex_path = OUTPUT_DIR / "poster_latex_proj" / "poster_output.tex"
    logos_dir = OUTPUT_DIR / "poster_latex_proj" / "logos"
    try:
        if not tex_path.exists():
            return False
        # any left_logo.* present?
        has_left = False
        if logos_dir.exists():
            for p in logos_dir.iterdir():
                if p.is_file() and p.stem == "left_logo":
                    has_left = True
                    break
        if has_left:
            return False
        txt = tex_path.read_text(encoding="utf-8")
        if "\\logoleft" in txt:
            new_txt = re.sub(r"^\\\s*logoleft\s*\{.*?\}\s*$", lambda m: "%" + m.group(0), txt, flags=re.MULTILINE)
            if new_txt != txt:
                tex_path.write_text(new_txt, encoding="utf-8")
                logs.append("ℹ️ No left_logo found; disabled \\\logoleft in poster_output.tex.")
                return True
    except Exception as e:
        logs.append(f"⚠️ Failed left_logo fallback: {e}")
    return False

def debug_compile():
    # Disabled minimal LaTeX debug; use the two pipeline-zip tests instead.
    return "<div style='color:#555'>Minimal debug disabled. Use 'Test repo output.zip' or 'Test last pipeline zip'.</div>"

def debug_compile_output_zip():
    """Compile the repo-root output.zip (a real LaTeX project) and return preview HTML + PDF path."""
    # Stage repo output.zip to runs/<id>/output.zip to follow pipeline layout, then delegate
    zip_path = ROOT / "output.zip"
    if not zip_path.exists():
        return (
            "<div style='color:#b00'><b>output.zip not found at repo root.</b></div>"
            + f"<div>Expected at: {zip_path}</div>",
            None,
        )
    logs = [f"🐞 Stage(repo zip) at {_now_str()}"]
    _, WORK_DIR, LOG_PATH, ZIP_PATH = _prepare_workspace(logs)
    try:
        shutil.copy2(zip_path, ZIP_PATH)
        logs.append(f"📦 Staged repo output.zip → runs/{WORK_DIR.name}/output.zip")
        _write_logs(LOG_PATH, logs)
    except Exception as e:
        logs.append(f"❌ Failed staging output.zip: {e}")
        _write_logs(LOG_PATH, logs)
        return "<div style='color:#b00'>Failed to stage output.zip</div>"
    return debug_compile_last_pipeline_zip()
    logs = [f"🐞 Debug(real) at {_now_str()}"]
    zip_path = ROOT / "output.zip"
    if not zip_path.exists():
        return (
            "<div style='color:#b00'><b>output.zip not found at repo root.</b></div>"
            + f"<div>Expected at: {zip_path}</div>"
        )

    # Prepare workspace
    run_id, WORK_DIR, LOG_PATH, _ = _prepare_workspace(logs)
    work_zip_dir = WORK_DIR / "zip_proj"
    work_zip_dir.mkdir(parents=True, exist_ok=True)
    logs.append(f"Workspace: runs/{WORK_DIR.name}")
    logs.append("Unzipping output.zip → zip_proj/")

    # Extract zip
    try:
        import zipfile as _zf
        with _zf.ZipFile(zip_path, 'r') as zf:
            zf.extractall(work_zip_dir)
    except Exception as e:
        logs.append(f"❌ unzip failed: {e}")
        _write_logs(LOG_PATH, logs)
        return "<div style='color:#b00'>Unzip failed.</div>", None

    # Locate poster_output.tex (fallback to poster.tex)
    tex_path = None
    for name in ("poster_output.tex", "poster.tex"):
        cand = list(work_zip_dir.rglob(name))
        if cand:
            tex_path = cand[0]
            break
    if tex_path is None:
        # fallback: any .tex
        cand = list(work_zip_dir.rglob("*.tex"))
        if cand:
            tex_path = cand[0]
    if tex_path is None:
        logs.append("❌ No .tex file found in output.zip")
        _write_logs(LOG_PATH, logs)
        return "<div style='color:#b00'>No .tex found in output.zip</div>", None

    # If left_logo missing, disable \logoleft
    try:
        logos_dir = tex_path.parent / "logos"
        has_left = False
        if logos_dir.exists():
            for p in logos_dir.iterdir():
                if p.is_file() and p.stem == "left_logo":
                    has_left = True
                    break
        if not has_left:
            txt = tex_path.read_text(encoding="utf-8")
            if "\\logoleft" in txt:
                import re as _re
                new_txt = _re.sub(r"^\\\s*logoleft\s*\{.*?\}\s*$", lambda m: "%" + m.group(0), txt, flags=_re.MULTILINE)
                if new_txt != txt:
                    tex_path.write_text(new_txt, encoding="utf-8")
                    logs.append("ℹ️ No left_logo found; disabled \\logoleft in zip project.")
    except Exception as e:
        logs.append(f"⚠️ left_logo adjust failed: {e}")

    # Ensure local fonts present in project and override theme to use them
    try:
        src_fonts = ROOT / "template" / "fonts"
        dst_fonts = work_zip_dir / "fonts"
        if src_fonts.exists():
            for root_dir, dirs, files in os.walk(src_fonts):
                rel = Path(root_dir).relative_to(src_fonts)
                out_dir = dst_fonts / rel
                out_dir.mkdir(parents=True, exist_ok=True)
                for fn in files:
                    if fn.lower().endswith((".ttf", ".otf")):
                        shutil.copy2(Path(root_dir)/fn, out_dir/fn)
            logs.append("📁 Copied local fonts → zip_proj/fonts/")
        # Copy repository theme .sty files into project root so they take precedence
        try:
            tmpl_dir = ROOT / "template"
            for sty in tmpl_dir.glob("*.sty"):
                shutil.copy2(sty, work_zip_dir / sty.name)
            logs.append("📄 Copied template/*.sty → zip_proj/")
        except Exception as e:
            logs.append(f"⚠️ Copy sty failed: {e}")
        # Copy repository theme .sty files into both root and the .tex dir so they take precedence
        try:
            tmpl_dir = ROOT / "template"
            for sty in tmpl_dir.glob("*.sty"):
                shutil.copy2(sty, work_zip_dir / sty.name)
                shutil.copy2(sty, tex_path.parent / sty.name)
            logs.append("📄 Copied template/*.sty → zip_proj/ and tex dir")
        except Exception as e:
            logs.append(f"⚠️ Copy sty failed: {e}")
        # Append overrides into theme file(s)
        for sty in work_zip_dir.rglob("beamerthemegemini.sty"):
            with open(sty, "a", encoding="utf-8") as fh:
                fh.write("\n% === overrides inserted for local fonts ===\n")
                fh.write("\\makeatletter\\@ifpackageloaded{fontspec}{%\n")
                fh.write("\\IfFileExists{fonts/Raleway/Raleway-Regular.ttf}{\\renewfontfamily\\Raleway[Path=fonts/Raleway/, UprightFont=*-Regular, ItalicFont=*-Italic, BoldFont=*-Bold, BoldItalicFont=*-BoldItalic, Ligatures=TeX]{Raleway}}{}\n")
                fh.write("\\IfFileExists{fonts/Lato/Lato-Regular.ttf}{\\renewfontfamily\\Lato[Path=fonts/Lato/, UprightFont=*-Light, ItalicFont=*-LightItalic, BoldFont=*-Regular, BoldItalicFont=*-Italic, Ligatures=TeX]{Lato}\\setsansfont{Lato}[Path=fonts/Lato/, UprightFont=*-Light, ItalicFont=*-LightItalic, BoldFont=*-Regular, BoldItalicFont=*-Italic]}{}\n")
                fh.write("}{ }\\makeatother\n")
            logs.append(f"🛠️ Appended local font overrides in {sty.relative_to(work_zip_dir)}")
    except Exception as e:
        logs.append(f"⚠️ Local font setup failed: {e}")

    # Compile to PDF
    pdf_path = _compile_tex_to_pdf(tex_path, logs)
    if not pdf_path or not pdf_path.exists():
        logs.append("❌ Failed to compile zip PDF.")
        _write_logs(LOG_PATH, logs)
        return (
            "<div style='color:#b00'><b>Compile failed.</b></div>"
            + "<pre style='white-space:pre-wrap;background:#f7f7f8;padding:8px;border-radius:6px'>"
            + "\n".join(logs)
            + "</pre>",
            None,
        )

    try:
        # Use served file path to avoid data: URI issues
        html = _pdf_preview_html(pdf_path, height="700px")
        _write_logs(LOG_PATH, logs)
        return html, str(pdf_path)
    except Exception as e:
        logs.append(f"⚠️ preview failed: {e}")
        _write_logs(LOG_PATH, logs)
        return f"<div>Compiled but preview failed: {e}</div>", None

def _find_last_pipeline_zip():
    try:
        candidates = []
        for d in RUNS_DIR.iterdir():
            try:
                if d.is_dir():
                    z = d / "output.zip"
                    if z.exists():
                        candidates.append((z.stat().st_mtime, z))
            except Exception:
                pass
        if not candidates:
            return None
        candidates.sort(key=lambda x: x[0], reverse=True)
        return candidates[0][1]
    except Exception:
        return None

def debug_compile_last_pipeline_zip():
    """Find the most recent runs/*/output.zip from pipeline, compile, and return preview HTML + PDF path."""
    logs = [f"🐞 Debug(last-pipeline-zip) at {_now_str()}"]
    last_zip = _find_last_pipeline_zip()
    if not last_zip:
        repo_zip = ROOT / "output.zip"
        if repo_zip.exists():
            try:
                _, W, L, Z = _prepare_workspace(logs)
                shutil.copy2(repo_zip, Z)
                logs.append(f"📦 Auto-staged repo output.zip → runs/{W.name}/output.zip")
                last_zip = Z
            except Exception as e:
                logs.append(f"❌ Auto-stage failed: {e}")
                return "<div style='color:#b00'>No recent pipeline output.zip found and auto-stage failed.</div>"
        else:
            return "<div style='color:#b00'>No recent pipeline output.zip found under runs/.</div>", None

    # Prepare workspace
    run_id, WORK_DIR, LOG_PATH, _ = _prepare_workspace(logs)
    work_zip_dir = WORK_DIR / "zip_last"
    work_zip_dir.mkdir(parents=True, exist_ok=True)
    logs.append(f"Workspace: runs/{WORK_DIR.name}")
    logs.append(f"Using: {last_zip}")

    # Extract zip
    try:
        import zipfile as _zf
        with _zf.ZipFile(last_zip, 'r') as zf:
            zf.extractall(work_zip_dir)
    except Exception as e:
        logs.append(f"❌ unzip failed: {e}")
        _write_logs(LOG_PATH, logs)
        return "<div style='color:#b00'>Unzip failed.</div>", None

    # Locate tex
    tex_path = None
    for name in ("poster_output.tex", "poster.tex"):
        cand = list(work_zip_dir.rglob(name))
        if cand:
            tex_path = cand[0]
            break
    if tex_path is None:
        cand = list(work_zip_dir.rglob("*.tex"))
        if cand:
            tex_path = cand[0]
    if tex_path is None:
        logs.append("❌ No .tex file found in last pipeline zip")
        _write_logs(LOG_PATH, logs)
        return "<div style='color:#b00'>No .tex found in last pipeline zip</div>", None

    # Ensure local fonts and theme precedence (same as other debug path)
    try:
        src_fonts = ROOT / "template" / "fonts"
        dst_fonts = work_zip_dir / "fonts"
        if src_fonts.exists():
            for root_dir, dirs, files in os.walk(src_fonts):
                rel = Path(root_dir).relative_to(src_fonts)
                out_dir = dst_fonts / rel
                out_dir.mkdir(parents=True, exist_ok=True)
                for fn in files:
                    if fn.lower().endswith((".ttf", ".otf")):
                        shutil.copy2(Path(root_dir)/fn, out_dir/fn)
            logs.append("📁 Copied local fonts → zip_last/fonts/")
        # Copy repository theme .sty next to tex and at root
        try:
            tmpl_dir = ROOT / "template"
            for sty in tmpl_dir.glob("*.sty"):
                shutil.copy2(sty, work_zip_dir / sty.name)
                shutil.copy2(sty, tex_path.parent / sty.name)
            logs.append("📄 Copied template/*.sty → zip_last/ and tex dir")
        except Exception as e:
            logs.append(f"⚠️ Copy sty failed: {e}")
    except Exception as e:
        logs.append(f"⚠️ Local font setup failed: {e}")

    # Compile to PDF
    pdf_path = _compile_tex_to_pdf(tex_path, logs)
    if not pdf_path or not pdf_path.exists():
        logs.append("❌ Failed to compile last pipeline zip PDF.")
        _write_logs(LOG_PATH, logs)
        return (
            "<div style='color:#b00'><b>Compile failed.</b></div>"
            + "<pre style='white-space:pre-wrap;background:#f7f7f8;padding:8px;border-radius:6px'>"
            + "\n".join(logs)
            + "</pre>",
            None,
        )
    try:
        _write_logs(LOG_PATH, logs)
        return "", str(pdf_path)
    except Exception as e:
        logs.append(f"⚠️ preview failed: {e}")
        _write_logs(LOG_PATH, logs)
        return f"<div>Compiled but preview failed: {e}</div>", None

def _find_last_pipeline_zip():
    try:
        candidates = []
        for d in RUNS_DIR.iterdir():
            try:
                if d.is_dir():
                    z = d / "output.zip"
                    if z.exists():
                        candidates.append((z.stat().st_mtime, z))
            except Exception:
                pass
        if not candidates:
            return None
        candidates.sort(key=lambda x: x[0], reverse=True)
        return candidates[0][1]
    except Exception:
        return None

def debug_compile_last_pipeline_zip():
    """Find the most recent runs/*/output.zip from pipeline, compile, and return preview HTML + PDF path."""
    logs = [f"🐞 Debug(last-pipeline-zip) at {_now_str()}"]
    last_zip = _find_last_pipeline_zip()
    if not last_zip:
        repo_zip = ROOT / "output.zip"
        if repo_zip.exists():
            try:
                _, W, L, Z = _prepare_workspace(logs)
                shutil.copy2(repo_zip, Z)
                logs.append(f"📦 Auto-staged repo output.zip → runs/{W.name}/output.zip")
                last_zip = Z
            except Exception as e:
                logs.append(f"❌ Auto-stage failed: {e}")
                return "<div style='color:#b00'>No recent pipeline output.zip found and auto-stage failed.</div>"
        else:
            return "<div style='color:#b00'>No recent pipeline output.zip found under runs/.</div>", None

    # Prepare workspace
    run_id, WORK_DIR, LOG_PATH, _ = _prepare_workspace(logs)
    work_zip_dir = WORK_DIR / "zip_last"
    work_zip_dir.mkdir(parents=True, exist_ok=True)
    logs.append(f"Workspace: runs/{WORK_DIR.name}")
    logs.append(f"Using: {last_zip}")

    # Extract zip
    try:
        import zipfile as _zf
        with _zf.ZipFile(last_zip, 'r') as zf:
            zf.extractall(work_zip_dir)
    except Exception as e:
        logs.append(f"❌ unzip failed: {e}")
        _write_logs(LOG_PATH, logs)
        return "<div style='color:#b00'>Unzip failed.</div>", None

    # Locate tex
    tex_path = None
    for name in ("poster_output.tex", "poster.tex"):
        cand = list(work_zip_dir.rglob(name))
        if cand:
            tex_path = cand[0]
            break
    if tex_path is None:
        cand = list(work_zip_dir.rglob("*.tex"))
        if cand:
            tex_path = cand[0]
    if tex_path is None:
        logs.append("❌ No .tex file found in last pipeline zip")
        _write_logs(LOG_PATH, logs)
        return "<div style='color:#b00'>No .tex found in last pipeline zip</div>", None

    # Ensure local fonts and theme precedence (same as other debug path)
    try:
        src_fonts = ROOT / "template" / "fonts"
        dst_fonts = work_zip_dir / "fonts"
        if src_fonts.exists():
            for root_dir, dirs, files in os.walk(src_fonts):
                rel = Path(root_dir).relative_to(src_fonts)
                out_dir = dst_fonts / rel
                out_dir.mkdir(parents=True, exist_ok=True)
                for fn in files:
                    if fn.lower().endswith((".ttf", ".otf")):
                        shutil.copy2(Path(root_dir)/fn, out_dir/fn)
            logs.append("📁 Copied local fonts → zip_last/fonts/")
        # Copy repository theme .sty next to tex and at root
        try:
            tmpl_dir = ROOT / "template"
            for sty in tmpl_dir.glob("*.sty"):
                shutil.copy2(sty, work_zip_dir / sty.name)
                shutil.copy2(sty, tex_path.parent / sty.name)
            logs.append("📄 Copied template/*.sty → zip_last/ and tex dir")
        except Exception as e:
            logs.append(f"⚠️ Copy sty failed: {e}")
    except Exception as e:
        logs.append(f"⚠️ Local font setup failed: {e}")

    # Compile to PDF
    pdf_path = _compile_tex_to_pdf(tex_path, logs)
    if not pdf_path or not pdf_path.exists():
        logs.append("❌ Failed to compile last pipeline zip PDF.")
        _write_logs(LOG_PATH, logs)
        return (
            "<div style='color:#b00'><b>Compile failed.</b></div>"
            + "<pre style='white-space:pre-wrap;background:#f7f7f8;padding:8px;border-radius:6px'>"
            + "\n".join(logs)
            + "</pre>",
            None,
        )
    try:
        b64 = base64.b64encode(pdf_path.read_bytes()).decode("utf-8")
        open_tab = f"<a target='_blank' rel='noopener' href='data:application/pdf;base64,{b64}'>Open PDF in new tab</a>"
        html = (
            f"<div style='margin-bottom:8px'>{open_tab}</div>"
            + _pdf_to_iframe_html(pdf_path, height="700px")
        )
        _write_logs(LOG_PATH, logs)
        return "", str(pdf_path)
    except Exception as e:
        logs.append(f"⚠️ preview failed: {e}")
        _write_logs(LOG_PATH, logs)
        return f"<div>Compiled but preview failed: {e}</div>", None

def debug_compile_uploaded_zip(zip_file):
    """Compile an uploaded poster zip (user-provided); return preview HTML + PDF path."""
    logs = [f"🐞 Debug(upload) at {_now_str()}"]
    if not zip_file:
        return "<div style='color:#b00'>Please upload a .zip file first.</div>", None
    # Prepare workspace
    run_id, WORK_DIR, LOG_PATH, _ = _prepare_workspace(logs)
    work_zip_dir = WORK_DIR / "zip_upload"
    work_zip_dir.mkdir(parents=True, exist_ok=True)
    # Save uploaded zip
    up_path = work_zip_dir / "input.zip"
    try:
        shutil.copy(zip_file.name, up_path)
    except Exception as e:
        logs.append(f"❌ save upload failed: {e}")
        _write_logs(LOG_PATH, logs)
        return "<div style='color:#b00'>Save upload failed.</div>", None
    # Extract
    try:
        import zipfile as _zf
        with _zf.ZipFile(up_path, 'r') as zf:
            zf.extractall(work_zip_dir)
    except Exception as e:
        logs.append(f"❌ unzip failed: {e}")
        _write_logs(LOG_PATH, logs)
        return "<div style='color:#b00'>Unzip failed.</div>", None
    # Find tex
    tex_path = None
    for name in ("poster_output.tex", "poster.tex"):
        cand = list(work_zip_dir.rglob(name))
        if cand:
            tex_path = cand[0]
            break
    if tex_path is None:
        cand = list(work_zip_dir.rglob("*.tex"))
        if cand:
            tex_path = cand[0]
    if tex_path is None:
        logs.append("❌ No .tex file found in uploaded zip")
        _write_logs(LOG_PATH, logs)
        return "<div style='color:#b00'>No .tex found in uploaded zip</div>", None
    # Disable logoleft if missing; also ensure local fonts and override theme to use them
    try:
        logos_dir = tex_path.parent / "logos"
        has_left = False
        if logos_dir.exists():
            for p in logos_dir.iterdir():
                if p.is_file() and p.stem == "left_logo":
                    has_left = True
                    break
        if not has_left:
            txt = tex_path.read_text(encoding="utf-8")
            if "\\logoleft" in txt:
                import re as _re
                new_txt = _re.sub(r"^\\\s*logoleft\s*\{.*?\}\s*$", lambda m: "%" + m.group(0), txt, flags=_re.MULTILINE)
                if new_txt != txt:
                    tex_path.write_text(new_txt, encoding="utf-8")
                    logs.append("ℹ️ No left_logo found; disabled \\logoleft in uploaded project.")
        # Copy repo-local fonts into the zip project under ./fonts/, then append overrides into theme
        try:
            src_fonts = ROOT / "template" / "fonts"
            dst_fonts = work_zip_dir / "fonts"
            if src_fonts.exists():
                for root_dir, dirs, files in os.walk(src_fonts):
                    rel = Path(root_dir).relative_to(src_fonts)
                    out_dir = dst_fonts / rel
                    out_dir.mkdir(parents=True, exist_ok=True)
                    for fn in files:
                        if fn.lower().endswith((".ttf", ".otf")):
                            shutil.copy2(Path(root_dir)/fn, out_dir/fn)
                logs.append("📁 Copied local fonts → zip_upload/fonts/")
            for sty in work_zip_dir.rglob("beamerthemegemini.sty"):
                with open(sty, "a", encoding="utf-8") as fh:
                    fh.write("\n% === overrides inserted for local fonts ===\n")
                    fh.write("\\makeatletter\\@ifpackageloaded{fontspec}{%\n")
                    fh.write("\\IfFileExists{fonts/Raleway/Raleway-Regular.ttf}{\\renewfontfamily\\Raleway[Path=fonts/Raleway/, UprightFont=*-Regular, ItalicFont=*-Italic, BoldFont=*-Bold, BoldItalicFont=*-BoldItalic, Ligatures=TeX]{Raleway}}{}\n")
                    fh.write("\\IfFileExists{fonts/Lato/Lato-Regular.ttf}{\\renewfontfamily\\Lato[Path=fonts/Lato/, UprightFont=*-Light, ItalicFont=*-LightItalic, BoldFont=*-Regular, BoldItalicFont=*-Italic, Ligatures=TeX]{Lato}\\setsansfont{Lato}[Path=fonts/Lato/, UprightFont=*-Light, ItalicFont=*-LightItalic, BoldFont=*-Regular, BoldItalicFont=*-Italic]}{}\n")
                    fh.write("}{ }\\makeatother\n")
                logs.append(f"🛠️ Appended local font overrides in {sty.relative_to(work_zip_dir)}")
        except Exception as e:
            logs.append(f"⚠️ Local font setup failed: {e}")
    except Exception as e:
        logs.append(f"⚠️ left_logo adjust failed: {e}")
    # Compile
    pdf_path = _compile_tex_to_pdf(tex_path, logs)
    if not pdf_path or not pdf_path.exists():
        logs.append("❌ Failed to compile uploaded zip PDF.")
        _write_logs(LOG_PATH, logs)
        return (
            "<div style='color:#b00'><b>Compile failed.</b></div>"
            + "<pre style='white-space:pre-wrap;background:#f7f7f8;padding:8px;border-radius:6px'>"
            + "\n".join(logs)
            + "</pre>",
            None,
        )
    try:
        _write_logs(LOG_PATH, logs)
        return "", str(pdf_path)
    except Exception as e:
        logs.append(f"⚠️ preview failed: {e}")
        _write_logs(LOG_PATH, logs)
        return f"<div>Compiled but preview failed: {e}</div>", None

def debug_compile_output_zip():
    """Compile the repo-root output.zip (a real LaTeX project) and preview the resulting PDF."""
    # Stage repo output.zip to runs/<id>/output.zip to follow pipeline layout, then delegate
    zip_path = ROOT / "output.zip"
    if not zip_path.exists():
        return (
            "<div style='color:#b00'><b>output.zip not found at repo root.</b></div>"
            + f"<div>Expected at: {zip_path}</div>",
            None,
        )
    logs = [f"🐞 Stage(repo zip) at {_now_str()}"]
    _, WORK_DIR, LOG_PATH, ZIP_PATH = _prepare_workspace(logs)
    try:
        shutil.copy2(zip_path, ZIP_PATH)
        logs.append(f"📦 Staged repo output.zip → runs/{WORK_DIR.name}/output.zip")
        _write_logs(LOG_PATH, logs)
    except Exception as e:
        logs.append(f"❌ Failed staging output.zip: {e}")
        _write_logs(LOG_PATH, logs)
        return "<div style='color:#b00'>Failed to stage output.zip</div>"
    return debug_compile_last_pipeline_zip()
    logs = [f"🐞 Debug(real) at {_now_str()}"]
    zip_path = ROOT / "output.zip"
    if not zip_path.exists():
        return (
            "<div style='color:#b00'><b>output.zip not found at repo root.</b></div>"
            + f"<div>Expected at: {zip_path}</div>"
        )

    # Prepare workspace
    run_id, WORK_DIR, LOG_PATH, _ = _prepare_workspace(logs)
    work_zip_dir = WORK_DIR / "zip_proj"
    work_zip_dir.mkdir(parents=True, exist_ok=True)
    logs.append(f"Workspace: runs/{WORK_DIR.name}")
    logs.append("Unzipping output.zip → zip_proj/")

    # Extract zip
    try:
        import zipfile as _zf
        with _zf.ZipFile(zip_path, 'r') as zf:
            zf.extractall(work_zip_dir)
    except Exception as e:
        logs.append(f"❌ unzip failed: {e}")
        _write_logs(LOG_PATH, logs)
        return "<div style='color:#b00'>Unzip failed.</div>", None

    # Locate poster_output.tex (fallback to poster.tex)
    tex_path = None
    for name in ("poster_output.tex", "poster.tex"):
        cand = list(work_zip_dir.rglob(name))
        if cand:
            tex_path = cand[0]
            break
    if tex_path is None:
        # fallback: any .tex
        cand = list(work_zip_dir.rglob("*.tex"))
        if cand:
            tex_path = cand[0]
    if tex_path is None:
        logs.append("❌ No .tex file found in output.zip")
        _write_logs(LOG_PATH, logs)
        return "<div style='color:#b00'>No .tex found in output.zip</div>", None

    # If left_logo missing, disable \logoleft
    try:
        logos_dir = tex_path.parent / "logos"
        has_left = False
        if logos_dir.exists():
            for p in logos_dir.iterdir():
                if p.is_file() and p.stem == "left_logo":
                    has_left = True
                    break
        if not has_left:
            txt = tex_path.read_text(encoding="utf-8")
            if "\\logoleft" in txt:
                import re as _re
                new_txt = _re.sub(r"^\\\s*logoleft\s*\{.*?\}\s*$", lambda m: "%" + m.group(0), txt, flags=_re.MULTILINE)
                if new_txt != txt:
                    tex_path.write_text(new_txt, encoding="utf-8")
                    logs.append("ℹ️ No left_logo found; disabled \\logoleft in zip project.")
    except Exception as e:
        logs.append(f"⚠️ left_logo adjust failed: {e}")

    # Compile to PDF
    pdf_path = _compile_tex_to_pdf(tex_path, logs)
    if not pdf_path or not pdf_path.exists():
        logs.append("❌ Failed to compile zip PDF.")
        _write_logs(LOG_PATH, logs)
        return (
            "<div style='color:#b00'><b>Compile failed.</b></div>"
            + "<pre style='white-space:pre-wrap;background:#f7f7f8;padding:8px;border-radius:6px'>"
            + "\n".join(logs)
            + "</pre>",
            None,
        )

    try:
        b64 = base64.b64encode(pdf_path.read_bytes()).decode("utf-8")
        open_tab = f"<a target='_blank' rel='noopener' href='data:application/pdf;base64,{b64}'>Open PDF in new tab</a>"
        html = (
            f"<div style='margin-bottom:8px'>{open_tab}</div>"
            + _pdf_to_iframe_html(pdf_path, height="700px")
        )
        _write_logs(LOG_PATH, logs)
        return "", str(pdf_path)
    except Exception as e:
        logs.append(f"⚠️ preview failed: {e}")
        _write_logs(LOG_PATH, logs)
        return f"<div>Compiled but preview failed: {e}</div>", None

def _find_last_pipeline_zip():
    try:
        candidates = []
        for d in RUNS_DIR.iterdir():
            try:
                if d.is_dir():
                    z = d / "output.zip"
                    if z.exists():
                        candidates.append((z.stat().st_mtime, z))
            except Exception:
                pass
        if not candidates:
            return None
        candidates.sort(key=lambda x: x[0], reverse=True)
        return candidates[0][1]
    except Exception:
        return None

def debug_compile_last_pipeline_zip():
    """Find the most recent runs/*/output.zip from pipeline, compile, and return preview HTML + PDF path."""
    logs = [f"🐞 Debug(last-pipeline-zip) at {_now_str()}"]
    last_zip = _find_last_pipeline_zip()
    if not last_zip:
        repo_zip = ROOT / "output.zip"
        if repo_zip.exists():
            try:
                _, W, L, Z = _prepare_workspace(logs)
                shutil.copy2(repo_zip, Z)
                logs.append(f"📦 Auto-staged repo output.zip → runs/{W.name}/output.zip")
                last_zip = Z
            except Exception as e:
                logs.append(f"❌ Auto-stage failed: {e}")
                return "<div style='color:#b00'>No recent pipeline output.zip found and auto-stage failed.</div>"
        else:
            return "<div style='color:#b00'>No recent pipeline output.zip found under runs/.</div>", None

    # Prepare workspace
    run_id, WORK_DIR, LOG_PATH, _ = _prepare_workspace(logs)
    work_zip_dir = WORK_DIR / "zip_last"
    work_zip_dir.mkdir(parents=True, exist_ok=True)
    logs.append(f"Workspace: runs/{WORK_DIR.name}")
    logs.append(f"Using: {last_zip}")

    # Extract zip
    try:
        import zipfile as _zf
        with _zf.ZipFile(last_zip, 'r') as zf:
            zf.extractall(work_zip_dir)
    except Exception as e:
        logs.append(f"❌ unzip failed: {e}")
        _write_logs(LOG_PATH, logs)
        return "<div style='color:#b00'>Unzip failed.</div>", None

    # Locate tex
    tex_path = None
    for name in ("poster_output.tex", "poster.tex"):
        cand = list(work_zip_dir.rglob(name))
        if cand:
            tex_path = cand[0]
            break
    if tex_path is None:
        cand = list(work_zip_dir.rglob("*.tex"))
        if cand:
            tex_path = cand[0]
    if tex_path is None:
        logs.append("❌ No .tex file found in last pipeline zip")
        _write_logs(LOG_PATH, logs)
        return "<div style='color:#b00'>No .tex found in last pipeline zip</div>", None

    # Ensure local fonts and theme precedence (same as other debug path)
    try:
        src_fonts = ROOT / "template" / "fonts"
        dst_fonts = work_zip_dir / "fonts"
        if src_fonts.exists():
            for root_dir, dirs, files in os.walk(src_fonts):
                rel = Path(root_dir).relative_to(src_fonts)
                out_dir = dst_fonts / rel
                out_dir.mkdir(parents=True, exist_ok=True)
                for fn in files:
                    if fn.lower().endswith((".ttf", ".otf")):
                        shutil.copy2(Path(root_dir)/fn, out_dir/fn)
            logs.append("📁 Copied local fonts → zip_last/fonts/")
        # Copy repository theme .sty next to tex and at root
        try:
            tmpl_dir = ROOT / "template"
            for sty in tmpl_dir.glob("*.sty"):
                shutil.copy2(sty, work_zip_dir / sty.name)
                shutil.copy2(sty, tex_path.parent / sty.name)
            logs.append("📄 Copied template/*.sty → zip_last/ and tex dir")
        except Exception as e:
            logs.append(f"⚠️ Copy sty failed: {e}")
    except Exception as e:
        logs.append(f"⚠️ Local font setup failed: {e}")

    # Compile to PDF
    pdf_path = _compile_tex_to_pdf(tex_path, logs)
    if not pdf_path or not pdf_path.exists():
        logs.append("❌ Failed to compile last pipeline zip PDF.")
        _write_logs(LOG_PATH, logs)
        return (
            "<div style='color:#b00'><b>Compile failed.</b></div>"
            + "<pre style='white-space:pre-wrap;background:#f7f7f8;padding:8px;border-radius:6px'>"
            + "\n".join(logs)
            + "</pre>",
            None,
        )
    try:
        b64 = base64.b64encode(pdf_path.read_bytes()).decode("utf-8")
        open_tab = f"<a target='_blank' rel='noopener' href='data:application/pdf;base64,{b64}'>Open PDF in new tab</a>"
        html = (
            f"<div style='margin-bottom:8px'>{open_tab}</div>"
            + _pdf_to_iframe_html(pdf_path, height="700px")
        )
        _write_logs(LOG_PATH, logs)
        return "", str(pdf_path)
    except Exception as e:
        logs.append(f"⚠️ preview failed: {e}")
        _write_logs(LOG_PATH, logs)
        return f"<div>Compiled but preview failed: {e}</div>", None

def debug_compile_uploaded_zip(zip_file):
    """Compile an uploaded poster zip (user-provided); return preview HTML + PDF path."""
    logs = [f"🐞 Debug(upload) at {_now_str()}"]
    if not zip_file:
        return "<div style='color:#b00'>Please upload a .zip file first.</div>", None
    # Prepare workspace
    run_id, WORK_DIR, LOG_PATH, _ = _prepare_workspace(logs)
    work_zip_dir = WORK_DIR / "zip_upload"
    work_zip_dir.mkdir(parents=True, exist_ok=True)
    # Save uploaded zip
    up_path = work_zip_dir / "input.zip"
    try:
        shutil.copy(zip_file.name, up_path)
    except Exception as e:
        logs.append(f"❌ save upload failed: {e}")
        _write_logs(LOG_PATH, logs)
        return "<div style='color:#b00'>Save upload failed.</div>", None
    # Extract
    try:
        import zipfile as _zf
        with _zf.ZipFile(up_path, 'r') as zf:
            zf.extractall(work_zip_dir)
    except Exception as e:
        logs.append(f"❌ unzip failed: {e}")
        _write_logs(LOG_PATH, logs)
        return "<div style='color:#b00'>Unzip failed.</div>", None
    # Find tex
    tex_path = None
    for name in ("poster_output.tex", "poster.tex"):
        cand = list(work_zip_dir.rglob(name))
        if cand:
            tex_path = cand[0]
            break
    if tex_path is None:
        cand = list(work_zip_dir.rglob("*.tex"))
        if cand:
            tex_path = cand[0]
    if tex_path is None:
        logs.append("❌ No .tex file found in uploaded zip")
        _write_logs(LOG_PATH, logs)
        return "<div style='color:#b00'>No .tex found in uploaded zip</div>"
    # Disable logoleft if missing
    try:
        logos_dir = tex_path.parent / "logos"
        has_left = False
        if logos_dir.exists():
            for p in logos_dir.iterdir():
                if p.is_file() and p.stem == "left_logo":
                    has_left = True
                    break
        if not has_left:
            txt = tex_path.read_text(encoding="utf-8")
            if "\\logoleft" in txt:
                import re as _re
                new_txt = _re.sub(r"^\\\s*logoleft\s*\{.*?\}\s*$", lambda m: "%" + m.group(0), txt, flags=_re.MULTILINE)
                if new_txt != txt:
                    tex_path.write_text(new_txt, encoding="utf-8")
                    logs.append("ℹ️ No left_logo found; disabled \\logoleft in uploaded project.")
    except Exception as e:
        logs.append(f"⚠️ left_logo adjust failed: {e}")
    # Compile
    pdf_path = _compile_tex_to_pdf(tex_path, logs)
    if not pdf_path or not pdf_path.exists():
        logs.append("❌ Failed to compile uploaded zip PDF.")
        _write_logs(LOG_PATH, logs)
        return (
            "<div style='color:#b00'><b>Compile failed.</b></div>"
            + "<pre style='white-space:pre-wrap;background:#f7f7f8;padding:8px;border-radius:6px'>"
            + "\n".join(logs)
            + "</pre>",
            None,
        )
    try:
        b64 = base64.b64encode(pdf_path.read_bytes()).decode("utf-8")
        open_tab = f"<a target='_blank' rel='noopener' href='data:application/pdf;base64,{b64}'>Open PDF in new tab</a>"
        html = (
            f"<div style='margin-bottom:8px'>{open_tab}</div>"
            + _pdf_to_iframe_html(pdf_path, height="700px")
        )
        _write_logs(LOG_PATH, logs)
        return html, str(pdf_path)
    except Exception as e:
        logs.append(f"⚠️ preview failed: {e}")
        _write_logs(LOG_PATH, logs)
        return f"<div>Compiled but preview failed: {e}</div>", None

def debug_compile_output_zip():
    """Compile the repo-root output.zip (a real LaTeX project) and preview the resulting PDF."""
    # Stage repo output.zip to runs/<id>/output.zip to follow pipeline layout, then delegate
    zip_path = ROOT / "output.zip"
    if not zip_path.exists():
        return (
            "<div style='color:#b00'><b>output.zip not found at repo root.</b></div>"
            + f"<div>Expected at: {zip_path}</div>",
            None,
        )
    logs = [f"🐞 Stage(repo zip) at {_now_str()}"]
    _, WORK_DIR, LOG_PATH, ZIP_PATH = _prepare_workspace(logs)
    try:
        shutil.copy2(zip_path, ZIP_PATH)
        logs.append(f"📦 Staged repo output.zip → runs/{WORK_DIR.name}/output.zip")
        _write_logs(LOG_PATH, logs)
    except Exception as e:
        logs.append(f"❌ Failed staging output.zip: {e}")
        _write_logs(LOG_PATH, logs)
        return "<div style='color:#b00'>Failed to stage output.zip</div>"
    return debug_compile_last_pipeline_zip()
    logs = [f"🐞 Debug(real) at {_now_str()}"]
    zip_path = ROOT / "output.zip"
    if not zip_path.exists():
        return (
            "<div style='color:#b00'><b>output.zip not found at repo root.</b></div>"
            + f"<div>Expected at: {zip_path}</div>"
        )

    # Prepare workspace
    run_id, WORK_DIR, LOG_PATH, _ = _prepare_workspace(logs)
    work_zip_dir = WORK_DIR / "zip_proj"
    work_zip_dir.mkdir(parents=True, exist_ok=True)
    logs.append(f"Workspace: runs/{WORK_DIR.name}")
    logs.append("Unzipping output.zip → zip_proj/")

    # Extract zip
    try:
        import zipfile as _zf
        with _zf.ZipFile(zip_path, 'r') as zf:
            zf.extractall(work_zip_dir)
    except Exception as e:
        logs.append(f"❌ unzip failed: {e}")
        _write_logs(LOG_PATH, logs)
        return "<div style='color:#b00'>Unzip failed.</div>", None

    # Locate poster_output.tex (fallback to poster.tex)
    tex_path = None
    for name in ("poster_output.tex", "poster.tex"):
        cand = list(work_zip_dir.rglob(name))
        if cand:
            tex_path = cand[0]
            break
    if tex_path is None:
        # fallback: any .tex
        cand = list(work_zip_dir.rglob("*.tex"))
        if cand:
            tex_path = cand[0]
    if tex_path is None:
        logs.append("❌ No .tex file found in output.zip")
        _write_logs(LOG_PATH, logs)
        return "<div style='color:#b00'>No .tex found in output.zip</div>", None

    # If left_logo missing, disable \logoleft
    try:
        logos_dir = tex_path.parent / "logos"
        has_left = False
        if logos_dir.exists():
            for p in logos_dir.iterdir():
                if p.is_file() and p.stem == "left_logo":
                    has_left = True
                    break
        if not has_left:
            txt = tex_path.read_text(encoding="utf-8")
            if "\\logoleft" in txt:
                import re as _re
                new_txt = _re.sub(r"^\\\s*logoleft\s*\{.*?\}\s*$", lambda m: "%" + m.group(0), txt, flags=_re.MULTILINE)
                if new_txt != txt:
                    tex_path.write_text(new_txt, encoding="utf-8")
                    logs.append("ℹ️ No left_logo found; disabled \\logoleft in zip project.")
    except Exception as e:
        logs.append(f"⚠️ left_logo adjust failed: {e}")

    # Compile to PDF
    pdf_path = _compile_tex_to_pdf(tex_path, logs)
    if not pdf_path or not pdf_path.exists():
        logs.append("❌ Failed to compile zip PDF.")
        _write_logs(LOG_PATH, logs)
        return (
            "<div style='color:#b00'><b>Compile failed.</b></div>"
            + "<pre style='white-space:pre-wrap;background:#f7f7f8;padding:8px;border-radius:6px'>"
            + "\n".join(logs)
            + "</pre>",
            None,
        )

    try:
        b64 = base64.b64encode(pdf_path.read_bytes()).decode("utf-8")
        open_tab = f"<a target='_blank' rel='noopener' href='data:application/pdf;base64,{b64}'>Open PDF in new tab</a>"
        html = (
            f"<div style='margin-bottom:8px'>{open_tab}</div>"
            + _pdf_to_iframe_html(pdf_path, height="700px")
        )
        _write_logs(LOG_PATH, logs)
        return html, str(pdf_path)
    except Exception as e:
        logs.append(f"⚠️ preview failed: {e}")
        _write_logs(LOG_PATH, logs)
        return f"<div>Compiled but preview failed: {e}</div>", None

def _find_last_pipeline_zip():
    try:
        candidates = []
        for d in RUNS_DIR.iterdir():
            try:
                if d.is_dir():
                    z = d / "output.zip"
                    if z.exists():
                        candidates.append((z.stat().st_mtime, z))
            except Exception:
                pass
        if not candidates:
            return None
        candidates.sort(key=lambda x: x[0], reverse=True)
        return candidates[0][1]
    except Exception:
        return None

def debug_compile_last_pipeline_zip():
    """Find the most recent runs/*/output.zip from pipeline, compile, and return preview HTML + PDF path."""
    logs = [f"🐞 Debug(last-pipeline-zip) at {_now_str()}"]
    last_zip = _find_last_pipeline_zip()
    if not last_zip:
        repo_zip = ROOT / "output.zip"
        if repo_zip.exists():
            try:
                _, W, L, Z = _prepare_workspace(logs)
                shutil.copy2(repo_zip, Z)
                logs.append(f"📦 Auto-staged repo output.zip → runs/{W.name}/output.zip")
                last_zip = Z
            except Exception as e:
                logs.append(f"❌ Auto-stage failed: {e}")
                return "<div style='color:#b00'>No recent pipeline output.zip found and auto-stage failed.</div>"
        else:
            return "<div style='color:#b00'>No recent pipeline output.zip found under runs/.</div>", None

    # Prepare workspace
    run_id, WORK_DIR, LOG_PATH, _ = _prepare_workspace(logs)
    work_zip_dir = WORK_DIR / "zip_last"
    work_zip_dir.mkdir(parents=True, exist_ok=True)
    logs.append(f"Workspace: runs/{WORK_DIR.name}")
    logs.append(f"Using: {last_zip}")

    # Extract zip
    try:
        import zipfile as _zf
        with _zf.ZipFile(last_zip, 'r') as zf:
            zf.extractall(work_zip_dir)
    except Exception as e:
        logs.append(f"❌ unzip failed: {e}")
        _write_logs(LOG_PATH, logs)
        return "<div style='color:#b00'>Unzip failed.</div>", None

    # Locate tex
    tex_path = None
    for name in ("poster_output.tex", "poster.tex"):
        cand = list(work_zip_dir.rglob(name))
        if cand:
            tex_path = cand[0]
            break
    if tex_path is None:
        cand = list(work_zip_dir.rglob("*.tex"))
        if cand:
            tex_path = cand[0]
    if tex_path is None:
        logs.append("❌ No .tex file found in last pipeline zip")
        _write_logs(LOG_PATH, logs)
        return "<div style='color:#b00'>No .tex found in last pipeline zip</div>", None

    # Ensure local fonts and theme precedence (same as other debug path)
    try:
        src_fonts = ROOT / "template" / "fonts"
        dst_fonts = work_zip_dir / "fonts"
        if src_fonts.exists():
            for root_dir, dirs, files in os.walk(src_fonts):
                rel = Path(root_dir).relative_to(src_fonts)
                out_dir = dst_fonts / rel
                out_dir.mkdir(parents=True, exist_ok=True)
                for fn in files:
                    if fn.lower().endswith((".ttf", ".otf")):
                        shutil.copy2(Path(root_dir)/fn, out_dir/fn)
            logs.append("📁 Copied local fonts → zip_last/fonts/")
        # Copy repository theme .sty next to tex and at root
        try:
            tmpl_dir = ROOT / "template"
            for sty in tmpl_dir.glob("*.sty"):
                shutil.copy2(sty, work_zip_dir / sty.name)
                shutil.copy2(sty, tex_path.parent / sty.name)
            logs.append("📄 Copied template/*.sty → zip_last/ and tex dir")
        except Exception as e:
            logs.append(f"⚠️ Copy sty failed: {e}")
    except Exception as e:
        logs.append(f"⚠️ Local font setup failed: {e}")

    # Compile to PDF
    pdf_path = _compile_tex_to_pdf(tex_path, logs)
    if not pdf_path or not pdf_path.exists():
        logs.append("❌ Failed to compile last pipeline zip PDF.")
        _write_logs(LOG_PATH, logs)
        return (
            "<div style='color:#b00'><b>Compile failed.</b></div>"
            + "<pre style='white-space:pre-wrap;background:#f7f7f8;padding:8px;border-radius:6px'>"
            + "\n".join(logs)
            + "</pre>",
            None,
        )
    try:
        b64 = base64.b64encode(pdf_path.read_bytes()).decode("utf-8")
        open_tab = f"<a target='_blank' rel='noopener' href='data:application/pdf;base64,{b64}'>Open PDF in new tab</a>"
        html = (
            f"<div style='margin-bottom:8px'>{open_tab}</div>"
            + _pdf_to_iframe_html(pdf_path, height="700px")
        )
        _write_logs(LOG_PATH, logs)
        return html, str(pdf_path)
    except Exception as e:
        logs.append(f"⚠️ preview failed: {e}")
        _write_logs(LOG_PATH, logs)
        return f"<div>Compiled but preview failed: {e}</div>", None

def debug_compile_uploaded_zip(zip_file):
    """Compile an uploaded poster zip (user-provided); return preview HTML + PDF path."""
    logs = [f"🐞 Debug(upload) at {_now_str()}"]
    if not zip_file:
        return "<div style='color:#b00'>Please upload a .zip file first.</div>", None
    # Prepare workspace
    run_id, WORK_DIR, LOG_PATH, _ = _prepare_workspace(logs)
    work_zip_dir = WORK_DIR / "zip_upload"
    work_zip_dir.mkdir(parents=True, exist_ok=True)
    # Save uploaded zip
    up_path = work_zip_dir / "input.zip"
    try:
        shutil.copy(zip_file.name, up_path)
    except Exception as e:
        logs.append(f"❌ save upload failed: {e}")
        _write_logs(LOG_PATH, logs)
        return "<div style='color:#b00'>Save upload failed.</div>", None
    # Extract
    try:
        import zipfile as _zf
        with _zf.ZipFile(up_path, 'r') as zf:
            zf.extractall(work_zip_dir)
    except Exception as e:
        logs.append(f"❌ unzip failed: {e}")
        _write_logs(LOG_PATH, logs)
        return "<div style='color:#b00'>Unzip failed.</div>", None
    # Find tex
    tex_path = None
    for name in ("poster_output.tex", "poster.tex"):
        cand = list(work_zip_dir.rglob(name))
        if cand:
            tex_path = cand[0]
            break
    if tex_path is None:
        cand = list(work_zip_dir.rglob("*.tex"))
        if cand:
            tex_path = cand[0]
    if tex_path is None:
        logs.append("❌ No .tex file found in uploaded zip")
        _write_logs(LOG_PATH, logs)
        return "<div style='color:#b00'>No .tex found in uploaded zip</div>"
    # Disable logoleft if missing
    try:
        logos_dir = tex_path.parent / "logos"
        has_left = False
        if logos_dir.exists():
            for p in logos_dir.iterdir():
                if p.is_file() and p.stem == "left_logo":
                    has_left = True
                    break
        if not has_left:
            txt = tex_path.read_text(encoding="utf-8")
            if "\\logoleft" in txt:
                import re as _re
                new_txt = _re.sub(r"^\\\s*logoleft\s*\{.*?\}\s*$", lambda m: "%" + m.group(0), txt, flags=_re.MULTILINE)
                if new_txt != txt:
                    tex_path.write_text(new_txt, encoding="utf-8")
                    logs.append("ℹ️ No left_logo found; disabled \\logoleft in uploaded project.")
    except Exception as e:
        logs.append(f"⚠️ left_logo adjust failed: {e}")
    # Compile
    pdf_path = _compile_tex_to_pdf(tex_path, logs)
    if not pdf_path or not pdf_path.exists():
        logs.append("❌ Failed to compile uploaded zip PDF.")
        _write_logs(LOG_PATH, logs)
        return (
            "<div style='color:#b00'><b>Compile failed.</b></div>"
            + "<pre style='white-space:pre-wrap;background:#f7f7f8;padding:8px;border-radius:6px'>"
            + "\n".join(logs)
            + "</pre>",
            None,
        )
    try:
        b64 = base64.b64encode(pdf_path.read_bytes()).decode("utf-8")
        open_tab = f"<a target='_blank' rel='noopener' href='data:application/pdf;base64,{b64}'>Open PDF in new tab</a>"
        html = (
            f"<div style='margin-bottom:8px'>{open_tab}</div>"
            + _pdf_to_iframe_html(pdf_path, height="700px")
        )
        _write_logs(LOG_PATH, logs)
        return "", str(pdf_path)
    except Exception as e:
        logs.append(f"⚠️ preview failed: {e}")
        _write_logs(LOG_PATH, logs)
        return f"<div>Compiled but preview failed: {e}</div>", None

def debug_compile_output_zip():
    """Compile the repo-root output.zip (a real LaTeX project) and preview the resulting PDF."""
    # Stage repo output.zip to runs/<id>/output.zip to follow pipeline layout, then delegate
    zip_path = ROOT / "output.zip"
    if not zip_path.exists():
        return (
            "<div style='color:#b00'><b>output.zip not found at repo root.</b></div>"
            + f"<div>Expected at: {zip_path}</div>",
            None,
        )
    logs = [f"🐞 Stage(repo zip) at {_now_str()}"]
    _, WORK_DIR, LOG_PATH, ZIP_PATH = _prepare_workspace(logs)
    try:
        shutil.copy2(zip_path, ZIP_PATH)
        logs.append(f"📦 Staged repo output.zip → runs/{WORK_DIR.name}/output.zip")
        _write_logs(LOG_PATH, logs)
    except Exception as e:
        logs.append(f"❌ Failed staging output.zip: {e}")
        _write_logs(LOG_PATH, logs)
        return "<div style='color:#b00'>Failed to stage output.zip</div>"
    return debug_compile_last_pipeline_zip()
    logs = [f"🐞 Debug(real) at {_now_str()}"]
    zip_path = ROOT / "output.zip"
    if not zip_path.exists():
        return (
            "<div style='color:#b00'><b>output.zip not found at repo root.</b></div>"
            + f"<div>Expected at: {zip_path}</div>"
        )

    # Prepare workspace
    run_id, WORK_DIR, LOG_PATH, _ = _prepare_workspace(logs)
    work_zip_dir = WORK_DIR / "zip_proj"
    work_zip_dir.mkdir(parents=True, exist_ok=True)
    logs.append(f"Workspace: runs/{WORK_DIR.name}")
    logs.append("Unzipping output.zip → zip_proj/")

    # Extract zip
    try:
        import zipfile as _zf
        with _zf.ZipFile(zip_path, 'r') as zf:
            zf.extractall(work_zip_dir)
    except Exception as e:
        logs.append(f"❌ unzip failed: {e}")
        _write_logs(LOG_PATH, logs)
        return "<div style='color:#b00'>Unzip failed.</div>", None

    # Locate poster_output.tex (fallback to poster.tex)
    tex_path = None
    for name in ("poster_output.tex", "poster.tex"):
        cand = list(work_zip_dir.rglob(name))
        if cand:
            tex_path = cand[0]
            break
    if tex_path is None:
        # fallback: any .tex
        cand = list(work_zip_dir.rglob("*.tex"))
        if cand:
            tex_path = cand[0]
    if tex_path is None:
        logs.append("❌ No .tex file found in output.zip")
        _write_logs(LOG_PATH, logs)
        return "<div style='color:#b00'>No .tex found in output.zip</div>", None

    # If left_logo missing, disable \logoleft
    try:
        logos_dir = tex_path.parent / "logos"
        has_left = False
        if logos_dir.exists():
            for p in logos_dir.iterdir():
                if p.is_file() and p.stem == "left_logo":
                    has_left = True
                    break
        if not has_left:
            txt = tex_path.read_text(encoding="utf-8")
            if "\\logoleft" in txt:
                import re as _re
                new_txt = _re.sub(r"^\\\s*logoleft\s*\{.*?\}\s*$", lambda m: "%" + m.group(0), txt, flags=_re.MULTILINE)
                if new_txt != txt:
                    tex_path.write_text(new_txt, encoding="utf-8")
                    logs.append("ℹ️ No left_logo found; disabled \\logoleft in zip project.")
    except Exception as e:
        logs.append(f"⚠️ left_logo adjust failed: {e}")

    # Compile to PDF
    pdf_path = _compile_tex_to_pdf(tex_path, logs)
    if not pdf_path or not pdf_path.exists():
        logs.append("❌ Failed to compile zip PDF.")
        _write_logs(LOG_PATH, logs)
        return (
            "<div style='color:#b00'><b>Compile failed.</b></div>"
            + "<pre style='white-space:pre-wrap;background:#f7f7f8;padding:8px;border-radius:6px'>"
            + "\n".join(logs)
            + "</pre>",
            None,
        )

    try:
        b64 = base64.b64encode(pdf_path.read_bytes()).decode("utf-8")
        open_tab = f"<a target='_blank' rel='noopener' href='data:application/pdf;base64,{b64}'>Open PDF in new tab</a>"
        html = (
            f"<div style='margin-bottom:8px'>{open_tab}</div>"
            + _pdf_to_iframe_html(pdf_path, height="700px")
        )
        _write_logs(LOG_PATH, logs)
        return html, str(pdf_path)
    except Exception as e:
        logs.append(f"⚠️ preview failed: {e}")
        _write_logs(LOG_PATH, logs)
        return f"<div>Compiled but preview failed: {e}</div>", None

def _find_last_pipeline_zip():
    try:
        candidates = []
        for d in RUNS_DIR.iterdir():
            try:
                if d.is_dir():
                    z = d / "output.zip"
                    if z.exists():
                        candidates.append((z.stat().st_mtime, z))
            except Exception:
                pass
        if not candidates:
            return None
        candidates.sort(key=lambda x: x[0], reverse=True)
        return candidates[0][1]
    except Exception:
        return None

def debug_compile_last_pipeline_zip():
    """Find the most recent runs/*/output.zip from pipeline, compile, and return preview HTML + PDF path."""
    logs = [f"🐞 Debug(last-pipeline-zip) at {_now_str()}"]
    last_zip = _find_last_pipeline_zip()
    if not last_zip:
        repo_zip = ROOT / "output.zip"
        if repo_zip.exists():
            try:
                _, W, L, Z = _prepare_workspace(logs)
                shutil.copy2(repo_zip, Z)
                logs.append(f"📦 Auto-staged repo output.zip → runs/{W.name}/output.zip")
                last_zip = Z
            except Exception as e:
                logs.append(f"❌ Auto-stage failed: {e}")
                return "<div style='color:#b00'>No recent pipeline output.zip found and auto-stage failed.</div>"
        else:
            return "<div style='color:#b00'>No recent pipeline output.zip found under runs/.</div>", None

    # Prepare workspace
    run_id, WORK_DIR, LOG_PATH, _ = _prepare_workspace(logs)
    work_zip_dir = WORK_DIR / "zip_last"
    work_zip_dir.mkdir(parents=True, exist_ok=True)
    logs.append(f"Workspace: runs/{WORK_DIR.name}")
    logs.append(f"Using: {last_zip}")

    # Extract zip
    try:
        import zipfile as _zf
        with _zf.ZipFile(last_zip, 'r') as zf:
            zf.extractall(work_zip_dir)
    except Exception as e:
        logs.append(f"❌ unzip failed: {e}")
        _write_logs(LOG_PATH, logs)
        return "<div style='color:#b00'>Unzip failed.</div>", None

    # Locate tex
    tex_path = None
    for name in ("poster_output.tex", "poster.tex"):
        cand = list(work_zip_dir.rglob(name))
        if cand:
            tex_path = cand[0]
            break
    if tex_path is None:
        cand = list(work_zip_dir.rglob("*.tex"))
        if cand:
            tex_path = cand[0]
    if tex_path is None:
        logs.append("❌ No .tex file found in last pipeline zip")
        _write_logs(LOG_PATH, logs)
        return "<div style='color:#b00'>No .tex found in last pipeline zip</div>", None

    # Ensure local fonts and theme precedence (same as other debug path)
    try:
        src_fonts = ROOT / "template" / "fonts"
        dst_fonts = work_zip_dir / "fonts"
        if src_fonts.exists():
            for root_dir, dirs, files in os.walk(src_fonts):
                rel = Path(root_dir).relative_to(src_fonts)
                out_dir = dst_fonts / rel
                out_dir.mkdir(parents=True, exist_ok=True)
                for fn in files:
                    if fn.lower().endswith((".ttf", ".otf")):
                        shutil.copy2(Path(root_dir)/fn, out_dir/fn)
            logs.append("📁 Copied local fonts → zip_last/fonts/")
        # Copy repository theme .sty next to tex and at root
        try:
            tmpl_dir = ROOT / "template"
            for sty in tmpl_dir.glob("*.sty"):
                shutil.copy2(sty, work_zip_dir / sty.name)
                shutil.copy2(sty, tex_path.parent / sty.name)
            logs.append("📄 Copied template/*.sty → zip_last/ and tex dir")
        except Exception as e:
            logs.append(f"⚠️ Copy sty failed: {e}")
    except Exception as e:
        logs.append(f"⚠️ Local font setup failed: {e}")

    # Compile to PDF
    pdf_path = _compile_tex_to_pdf(tex_path, logs)
    if not pdf_path or not pdf_path.exists():
        logs.append("❌ Failed to compile last pipeline zip PDF.")
        _write_logs(LOG_PATH, logs)
        return (
            "<div style='color:#b00'><b>Compile failed.</b></div>"
            + "<pre style='white-space:pre-wrap;background:#f7f7f8;padding:8px;border-radius:6px'>"
            + "\n".join(logs)
            + "</pre>",
            None,
        )
    try:
        b64 = base64.b64encode(pdf_path.read_bytes()).decode("utf-8")
        open_tab = f"<a target='_blank' rel='noopener' href='data:application/pdf;base64,{b64}'>Open PDF in new tab</a>"
        html = (
            f"<div style='margin-bottom:8px'>{open_tab}</div>"
            + _pdf_to_iframe_html(pdf_path, height="700px")
        )
        _write_logs(LOG_PATH, logs)
        return html, str(pdf_path)
    except Exception as e:
        logs.append(f"⚠️ preview failed: {e}")
        _write_logs(LOG_PATH, logs)
        return f"<div>Compiled but preview failed: {e}</div>", None

def debug_compile_uploaded_zip(zip_file):
    """Compile an uploaded poster zip (user-provided); return preview HTML + PDF path."""
    logs = [f"🐞 Debug(upload) at {_now_str()}"]
    if not zip_file:
        return "<div style='color:#b00'>Please upload a .zip file first.</div>", None
    # Prepare workspace
    run_id, WORK_DIR, LOG_PATH, _ = _prepare_workspace(logs)
    work_zip_dir = WORK_DIR / "zip_upload"
    work_zip_dir.mkdir(parents=True, exist_ok=True)
    # Save uploaded zip
    up_path = work_zip_dir / "input.zip"
    try:
        shutil.copy(zip_file.name, up_path)
    except Exception as e:
        logs.append(f"❌ save upload failed: {e}")
        _write_logs(LOG_PATH, logs)
        return "<div style='color:#b00'>Save upload failed.</div>", None
    # Extract
    try:
        import zipfile as _zf
        with _zf.ZipFile(up_path, 'r') as zf:
            zf.extractall(work_zip_dir)
    except Exception as e:
        logs.append(f"❌ unzip failed: {e}")
        _write_logs(LOG_PATH, logs)
        return "<div style='color:#b00'>Unzip failed.</div>", None
    # Find tex
    tex_path = None
    for name in ("poster_output.tex", "poster.tex"):
        cand = list(work_zip_dir.rglob(name))
        if cand:
            tex_path = cand[0]
            break
    if tex_path is None:
        cand = list(work_zip_dir.rglob("*.tex"))
        if cand:
            tex_path = cand[0]
    if tex_path is None:
        logs.append("❌ No .tex file found in uploaded zip")
        _write_logs(LOG_PATH, logs)
        return "<div style='color:#b00'>No .tex found in uploaded zip</div>"
    # Disable logoleft if missing
    try:
        logos_dir = tex_path.parent / "logos"
        has_left = False
        if logos_dir.exists():
            for p in logos_dir.iterdir():
                if p.is_file() and p.stem == "left_logo":
                    has_left = True
                    break
        if not has_left:
            txt = tex_path.read_text(encoding="utf-8")
            if "\\logoleft" in txt:
                import re as _re
                new_txt = _re.sub(r"^\\\s*logoleft\s*\{.*?\}\s*$", lambda m: "%" + m.group(0), txt, flags=_re.MULTILINE)
                if new_txt != txt:
                    tex_path.write_text(new_txt, encoding="utf-8")
                    logs.append("ℹ️ No left_logo found; disabled \\logoleft in uploaded project.")
    except Exception as e:
        logs.append(f"⚠️ left_logo adjust failed: {e}")
    # Compile
    pdf_path = _compile_tex_to_pdf(tex_path, logs)
    if not pdf_path or not pdf_path.exists():
        logs.append("❌ Failed to compile uploaded zip PDF.")
        _write_logs(LOG_PATH, logs)
        return (
            "<div style='color:#b00'><b>Compile failed.</b></div>"
            + "<pre style='white-space:pre-wrap;background:#f7f7f8;padding:8px;border-radius:6px'>"
            + "\n".join(logs)
            + "</pre>",
            None,
        )
    try:
        b64 = base64.b64encode(pdf_path.read_bytes()).decode("utf-8")
        open_tab = f"<a target='_blank' rel='noopener' href='data:application/pdf;base64,{b64}'>Open PDF in new tab</a>"
        html = (
            f"<div style='margin-bottom:8px'>{open_tab}</div>"
            + _pdf_to_iframe_html(pdf_path, height="700px")
        )
        _write_logs(LOG_PATH, logs)
        return "", str(pdf_path)
    except Exception as e:
        logs.append(f"⚠️ preview failed: {e}")
        _write_logs(LOG_PATH, logs)
        return f"<div>Compiled but preview failed: {e}</div>", None

def debug_compile_output_zip():
    """Compile the repo-root output.zip (a real LaTeX project) and preview the resulting PDF."""
    # Stage repo output.zip to runs/<id>/output.zip to follow pipeline layout, then delegate
    zip_path = ROOT / "output.zip"
    if not zip_path.exists():
        return (
            "<div style='color:#b00'><b>output.zip not found at repo root.</b></div>"
            + f"<div>Expected at: {zip_path}</div>",
            None,
        )
    logs = [f"🐞 Stage(repo zip) at {_now_str()}"]
    _, WORK_DIR, LOG_PATH, ZIP_PATH = _prepare_workspace(logs)
    try:
        shutil.copy2(zip_path, ZIP_PATH)
        logs.append(f"📦 Staged repo output.zip → runs/{WORK_DIR.name}/output.zip")
        _write_logs(LOG_PATH, logs)
    except Exception as e:
        logs.append(f"❌ Failed staging output.zip: {e}")
        _write_logs(LOG_PATH, logs)
        return "<div style='color:#b00'>Failed to stage output.zip</div>"
    return debug_compile_last_pipeline_zip()
    logs = [f"🐞 Debug(real) at {_now_str()}"]
    zip_path = ROOT / "output.zip"
    if not zip_path.exists():
        return (
            "<div style='color:#b00'><b>output.zip not found at repo root.</b></div>"
            + f"<div>Expected at: {zip_path}</div>",
            None,
        )

    # Prepare workspace
    run_id, WORK_DIR, LOG_PATH, _ = _prepare_workspace(logs)
    work_zip_dir = WORK_DIR / "zip_proj"
    work_zip_dir.mkdir(parents=True, exist_ok=True)
    logs.append(f"Workspace: runs/{WORK_DIR.name}")
    logs.append("Unzipping output.zip → zip_proj/")

    # Extract zip
    try:
        import zipfile as _zf
        with _zf.ZipFile(zip_path, 'r') as zf:
            zf.extractall(work_zip_dir)
    except Exception as e:
        logs.append(f"❌ unzip failed: {e}")
        _write_logs(LOG_PATH, logs)
        return "<div style='color:#b00'>Unzip failed.</div>", None

    # Locate poster_output.tex (fallback to poster.tex)
    tex_path = None
    for name in ("poster_output.tex", "poster.tex"):
        cand = list(work_zip_dir.rglob(name))
        if cand:
            tex_path = cand[0]
            break
    if tex_path is None:
        # fallback: any .tex
        cand = list(work_zip_dir.rglob("*.tex"))
        if cand:
            tex_path = cand[0]
    if tex_path is None:
        logs.append("❌ No .tex file found in output.zip")
        _write_logs(LOG_PATH, logs)
        return "<div style='color:#b00'>No .tex found in output.zip</div>", None

    # If left_logo missing, disable \logoleft
    try:
        logos_dir = tex_path.parent / "logos"
        has_left = False
        if logos_dir.exists():
            for p in logos_dir.iterdir():
                if p.is_file() and p.stem == "left_logo":
                    has_left = True
                    break
        if not has_left:
            txt = tex_path.read_text(encoding="utf-8")
            if "\\logoleft" in txt:
                import re as _re
                new_txt = _re.sub(r"^\\\s*logoleft\s*\{.*?\}\s*$", lambda m: "%" + m.group(0), txt, flags=_re.MULTILINE)
                if new_txt != txt:
                    tex_path.write_text(new_txt, encoding="utf-8")
                    logs.append("ℹ️ No left_logo found; disabled \\logoleft in zip project.")
    except Exception as e:
        logs.append(f"⚠️ left_logo adjust failed: {e}")

    # Compile to PDF
    pdf_path = _compile_tex_to_pdf(tex_path, logs)
    if not pdf_path or not pdf_path.exists():
        logs.append("❌ Failed to compile zip PDF.")
        _write_logs(LOG_PATH, logs)
        return (
            "<div style='color:#b00'><b>Compile failed.</b></div>"
            + "<pre style='white-space:pre-wrap;background:#f7f7f8;padding:8px;border-radius:6px'>"
            + "\n".join(logs)
            + "</pre>",
            None,
        )

    try:
        b64 = base64.b64encode(pdf_path.read_bytes()).decode("utf-8")
        open_tab = f"<a target='_blank' rel='noopener' href='data:application/pdf;base64,{b64}'>Open PDF in new tab</a>"
        html = (
            f"<div style='margin-bottom:8px'>{open_tab}</div>"
            + _pdf_to_iframe_html(pdf_path, height="700px")
        )
        _write_logs(LOG_PATH, logs)
        return html, str(pdf_path)
    except Exception as e:
        logs.append(f"⚠️ preview failed: {e}")
        _write_logs(LOG_PATH, logs)
        return f"<div>Compiled but preview failed: {e}</div>", None

def _find_last_pipeline_zip():
    try:
        candidates = []
        for d in RUNS_DIR.iterdir():
            try:
                if d.is_dir():
                    z = d / "output.zip"
                    if z.exists():
                        candidates.append((z.stat().st_mtime, z))
            except Exception:
                pass
        if not candidates:
            return None
        candidates.sort(key=lambda x: x[0], reverse=True)
        return candidates[0][1]
    except Exception:
        return None

def debug_compile_last_pipeline_zip():
    """Find the most recent runs/*/output.zip from pipeline, compile, and return preview HTML + PDF path."""
    logs = [f"🐞 Debug(last-pipeline-zip) at {_now_str()}"]
    last_zip = _find_last_pipeline_zip()
    if not last_zip:
        repo_zip = ROOT / "output.zip"
        if repo_zip.exists():
            try:
                _, W, L, Z = _prepare_workspace(logs)
                shutil.copy2(repo_zip, Z)
                logs.append(f"📦 Auto-staged repo output.zip → runs/{W.name}/output.zip")
                last_zip = Z
            except Exception as e:
                logs.append(f"❌ Auto-stage failed: {e}")
                return "<div style='color:#b00'>No recent pipeline output.zip found and auto-stage failed.</div>"
        else:
            return "<div style='color:#b00'>No recent pipeline output.zip found under runs/.</div>", None

    # Prepare workspace
    run_id, WORK_DIR, LOG_PATH, _ = _prepare_workspace(logs)
    work_zip_dir = WORK_DIR / "zip_last"
    work_zip_dir.mkdir(parents=True, exist_ok=True)
    logs.append(f"Workspace: runs/{WORK_DIR.name}")
    logs.append(f"Using: {last_zip}")

    # Extract zip
    try:
        import zipfile as _zf
        with _zf.ZipFile(last_zip, 'r') as zf:
            zf.extractall(work_zip_dir)
    except Exception as e:
        logs.append(f"❌ unzip failed: {e}")
        _write_logs(LOG_PATH, logs)
        return "<div style='color:#b00'>Unzip failed.</div>", None

    # Locate tex
    tex_path = None
    for name in ("poster_output.tex", "poster.tex"):
        cand = list(work_zip_dir.rglob(name))
        if cand:
            tex_path = cand[0]
            break
    if tex_path is None:
        cand = list(work_zip_dir.rglob("*.tex"))
        if cand:
            tex_path = cand[0]
    if tex_path is None:
        logs.append("❌ No .tex file found in last pipeline zip")
        _write_logs(LOG_PATH, logs)
        return "<div style='color:#b00'>No .tex found in last pipeline zip</div>", None

    # Ensure local fonts and theme precedence (same as other debug path)
    try:
        src_fonts = ROOT / "template" / "fonts"
        dst_fonts = work_zip_dir / "fonts"
        if src_fonts.exists():
            for root_dir, dirs, files in os.walk(src_fonts):
                rel = Path(root_dir).relative_to(src_fonts)
                out_dir = dst_fonts / rel
                out_dir.mkdir(parents=True, exist_ok=True)
                for fn in files:
                    if fn.lower().endswith((".ttf", ".otf")):
                        shutil.copy2(Path(root_dir)/fn, out_dir/fn)
            logs.append("📁 Copied local fonts → zip_last/fonts/")
        # Copy repository theme .sty next to tex and at root
        try:
            tmpl_dir = ROOT / "template"
            for sty in tmpl_dir.glob("*.sty"):
                shutil.copy2(sty, work_zip_dir / sty.name)
                shutil.copy2(sty, tex_path.parent / sty.name)
            logs.append("📄 Copied template/*.sty → zip_last/ and tex dir")
        except Exception as e:
            logs.append(f"⚠️ Copy sty failed: {e}")
    except Exception as e:
        logs.append(f"⚠️ Local font setup failed: {e}")

    # Compile to PDF
    pdf_path = _compile_tex_to_pdf(tex_path, logs)
    if not pdf_path or not pdf_path.exists():
        logs.append("❌ Failed to compile last pipeline zip PDF.")
        _write_logs(LOG_PATH, logs)
        return (
            "<div style='color:#b00'><b>Compile failed.</b></div>"
            + "<pre style='white-space:pre-wrap;background:#f7f7f8;padding:8px;border-radius:6px'>"
            + "\n".join(logs)
            + "</pre>",
            None,
        )
    try:
        b64 = base64.b64encode(pdf_path.read_bytes()).decode("utf-8")
        open_tab = f"<a target='_blank' rel='noopener' href='data:application/pdf;base64,{b64}'>Open PDF in new tab</a>"
        html = (
            f"<div style='margin-bottom:8px'>{open_tab}</div>"
            + _pdf_to_iframe_html(pdf_path, height="700px")
        )
        _write_logs(LOG_PATH, logs)
        return html, str(pdf_path)
    except Exception as e:
        logs.append(f"⚠️ preview failed: {e}")
        _write_logs(LOG_PATH, logs)
        return f"<div>Compiled but preview failed: {e}</div>", None

def debug_compile_uploaded_zip(zip_file):
    """Compile an uploaded poster zip (user-provided); return preview HTML + PDF path."""
    logs = [f"🐞 Debug(upload) at {_now_str()}"]
    if not zip_file:
        return "<div style='color:#b00'>Please upload a .zip file first.</div>", None
    # Prepare workspace
    run_id, WORK_DIR, LOG_PATH, _ = _prepare_workspace(logs)
    work_zip_dir = WORK_DIR / "zip_upload"
    work_zip_dir.mkdir(parents=True, exist_ok=True)
    # Save uploaded zip
    up_path = work_zip_dir / "input.zip"
    try:
        shutil.copy(zip_file.name, up_path)
    except Exception as e:
        logs.append(f"❌ save upload failed: {e}")
        _write_logs(LOG_PATH, logs)
        return "<div style='color:#b00'>Save upload failed.</div>", None
    # Extract
    try:
        import zipfile as _zf
        with _zf.ZipFile(up_path, 'r') as zf:
            zf.extractall(work_zip_dir)
    except Exception as e:
        logs.append(f"❌ unzip failed: {e}")
        _write_logs(LOG_PATH, logs)
        return "<div style='color:#b00'>Unzip failed.</div>", None
    # Find tex
    tex_path = None
    for name in ("poster_output.tex", "poster.tex"):
        cand = list(work_zip_dir.rglob(name))
        if cand:
            tex_path = cand[0]
            break
    if tex_path is None:
        cand = list(work_zip_dir.rglob("*.tex"))
        if cand:
            tex_path = cand[0]
    if tex_path is None:
        logs.append("❌ No .tex file found in uploaded zip")
        _write_logs(LOG_PATH, logs)
        return "<div style='color:#b00'>No .tex found in uploaded zip</div>"
    # Disable logoleft if missing
    try:
        logos_dir = tex_path.parent / "logos"
        has_left = False
        if logos_dir.exists():
            for p in logos_dir.iterdir():
                if p.is_file() and p.stem == "left_logo":
                    has_left = True
                    break
        if not has_left:
            txt = tex_path.read_text(encoding="utf-8")
            if "\\logoleft" in txt:
                import re as _re
                new_txt = _re.sub(r"^\\\s*logoleft\s*\{.*?\}\s*$", lambda m: "%" + m.group(0), txt, flags=_re.MULTILINE)
                if new_txt != txt:
                    tex_path.write_text(new_txt, encoding="utf-8")
                    logs.append("ℹ️ No left_logo found; disabled \\logoleft in uploaded project.")
    except Exception as e:
        logs.append(f"⚠️ left_logo adjust failed: {e}")
    # Compile
    pdf_path = _compile_tex_to_pdf(tex_path, logs)
    if not pdf_path or not pdf_path.exists():
        logs.append("❌ Failed to compile uploaded zip PDF.")
        _write_logs(LOG_PATH, logs)
        return (
            "<div style='color:#b00'><b>Compile failed.</b></div>"
            + "<pre style='white-space:pre-wrap;background:#f7f7f8;padding:8px;border-radius:6px'>"
            + "\n".join(logs)
            + "</pre>",
            None,
        )
    try:
        b64 = base64.b64encode(pdf_path.read_bytes()).decode("utf-8")
        open_tab = f"<a target='_blank' rel='noopener' href='data:application/pdf;base64,{b64}'>Open PDF in new tab</a>"
        html = (
            f"<div style='margin-bottom:8px'>{open_tab}</div>"
            + _pdf_to_iframe_html(pdf_path, height="700px")
        )
        _write_logs(LOG_PATH, logs)
        return "", str(pdf_path)
    except Exception as e:
        logs.append(f"⚠️ preview failed: {e}")
        _write_logs(LOG_PATH, logs)
        return f"<div>Compiled but preview failed: {e}</div>", None

def debug_compile_output_zip():
    """Compile the repo-root output.zip (a real LaTeX project) and preview the resulting PDF."""
    # Stage repo output.zip to runs/<id>/output.zip to follow pipeline layout, then delegate
    zip_path = ROOT / "output.zip"
    if not zip_path.exists():
        return (
            "<div style='color:#b00'><b>output.zip not found at repo root.</b></div>"
            + f"<div>Expected at: {zip_path}</div>",
            None,
        )
    logs = [f"🐞 Stage(repo zip) at {_now_str()}"]
    _, WORK_DIR, LOG_PATH, ZIP_PATH = _prepare_workspace(logs)
    try:
        shutil.copy2(zip_path, ZIP_PATH)
        logs.append(f"📦 Staged repo output.zip → runs/{WORK_DIR.name}/output.zip")
        _write_logs(LOG_PATH, logs)
    except Exception as e:
        logs.append(f"❌ Failed staging output.zip: {e}")
        _write_logs(LOG_PATH, logs)
        return "<div style='color:#b00'>Failed to stage output.zip</div>"
    return debug_compile_last_pipeline_zip()
    logs = [f"🐞 Debug(real) at {_now_str()}"]
    zip_path = ROOT / "output.zip"
    if not zip_path.exists():
        return (
            "<div style='color:#b00'><b>output.zip not found at repo root.</b></div>"
            + f"<div>Expected at: {zip_path}</div>",
            None,
        )

    # Prepare workspace
    run_id, WORK_DIR, LOG_PATH, _ = _prepare_workspace(logs)
    work_zip_dir = WORK_DIR / "zip_proj"
    work_zip_dir.mkdir(parents=True, exist_ok=True)
    logs.append(f"Workspace: runs/{WORK_DIR.name}")
    logs.append("Unzipping output.zip → zip_proj/")

    # Extract zip
    try:
        import zipfile as _zf
        with _zf.ZipFile(zip_path, 'r') as zf:
            zf.extractall(work_zip_dir)
    except Exception as e:
        logs.append(f"❌ unzip failed: {e}")
        _write_logs(LOG_PATH, logs)
        return "<div style='color:#b00'>Unzip failed.</div>", None

    # Locate poster_output.tex (fallback to poster.tex)
    tex_path = None
    for name in ("poster_output.tex", "poster.tex"):
        cand = list(work_zip_dir.rglob(name))
        if cand:
            tex_path = cand[0]
            break
    if tex_path is None:
        # fallback: any .tex
        cand = list(work_zip_dir.rglob("*.tex"))
        if cand:
            tex_path = cand[0]
    if tex_path is None:
        logs.append("❌ No .tex file found in output.zip")
        _write_logs(LOG_PATH, logs)
        return "<div style='color:#b00'>No .tex found in output.zip</div>", None

    # If left_logo missing, disable \logoleft
    try:
        logos_dir = tex_path.parent / "logos"
        has_left = False
        if logos_dir.exists():
            for p in logos_dir.iterdir():
                if p.is_file() and p.stem == "left_logo":
                    has_left = True
                    break
        if not has_left:
            txt = tex_path.read_text(encoding="utf-8")
            if "\\logoleft" in txt:
                import re as _re
                new_txt = _re.sub(r"^\\\s*logoleft\s*\{.*?\}\s*$", lambda m: "%" + m.group(0), txt, flags=_re.MULTILINE)
                if new_txt != txt:
                    tex_path.write_text(new_txt, encoding="utf-8")
                    logs.append("ℹ️ No left_logo found; disabled \\logoleft in zip project.")
    except Exception as e:
        logs.append(f"⚠️ left_logo adjust failed: {e}")

    # Compile to PDF
    pdf_path = _compile_tex_to_pdf(tex_path, logs)
    if not pdf_path or not pdf_path.exists():
        logs.append("❌ Failed to compile zip PDF.")
        _write_logs(LOG_PATH, logs)
        return (
            "<div style='color:#b00'><b>Compile failed.</b></div>"
            + "<pre style='white-space:pre-wrap;background:#f7f7f8;padding:8px;border-radius:6px'>"
            + "\n".join(logs)
            + "</pre>",
            None,
        )

    try:
        b64 = base64.b64encode(pdf_path.read_bytes()).decode("utf-8")
        open_tab = f"<a target='_blank' rel='noopener' href='data:application/pdf;base64,{b64}'>Open PDF in new tab</a>"
        html = (
            f"<div style='margin-bottom:8px'>{open_tab}</div>"
            + _pdf_to_iframe_html(pdf_path, height="700px")
        )
        _write_logs(LOG_PATH, logs)
        return html, str(pdf_path)
    except Exception as e:
        logs.append(f"⚠️ preview failed: {e}")
        _write_logs(LOG_PATH, logs)
        return f"<div>Compiled but preview failed: {e}</div>", None

def _find_last_pipeline_zip():
    try:
        candidates = []
        for d in RUNS_DIR.iterdir():
            try:
                if d.is_dir():
                    z = d / "output.zip"
                    if z.exists():
                        candidates.append((z.stat().st_mtime, z))
            except Exception:
                pass
        if not candidates:
            return None
        candidates.sort(key=lambda x: x[0], reverse=True)
        return candidates[0][1]
    except Exception:
        return None

def debug_compile_last_pipeline_zip():
    """Find the most recent runs/*/output.zip from pipeline, compile, and return preview HTML + PDF path."""
    logs = [f"🐞 Debug(last-pipeline-zip) at {_now_str()}"]
    last_zip = _find_last_pipeline_zip()
    if not last_zip:
        repo_zip = ROOT / "output.zip"
        if repo_zip.exists():
            try:
                _, W, L, Z = _prepare_workspace(logs)
                shutil.copy2(repo_zip, Z)
                logs.append(f"📦 Auto-staged repo output.zip → runs/{W.name}/output.zip")
                last_zip = Z
            except Exception as e:
                logs.append(f"❌ Auto-stage failed: {e}")
                return "<div style='color:#b00'>No recent pipeline output.zip found and auto-stage failed.</div>"
        else:
            return "<div style='color:#b00'>No recent pipeline output.zip found under runs/.</div>", None

    # Prepare workspace
    run_id, WORK_DIR, LOG_PATH, _ = _prepare_workspace(logs)
    work_zip_dir = WORK_DIR / "zip_last"
    work_zip_dir.mkdir(parents=True, exist_ok=True)
    logs.append(f"Workspace: runs/{WORK_DIR.name}")
    logs.append(f"Using: {last_zip}")

    # Extract zip
    try:
        import zipfile as _zf
        with _zf.ZipFile(last_zip, 'r') as zf:
            zf.extractall(work_zip_dir)
    except Exception as e:
        logs.append(f"❌ unzip failed: {e}")
        _write_logs(LOG_PATH, logs)
        return "<div style='color:#b00'>Unzip failed.</div>", None

    # Locate tex
    tex_path = None
    for name in ("poster_output.tex", "poster.tex"):
        cand = list(work_zip_dir.rglob(name))
        if cand:
            tex_path = cand[0]
            break
    if tex_path is None:
        cand = list(work_zip_dir.rglob("*.tex"))
        if cand:
            tex_path = cand[0]
    if tex_path is None:
        logs.append("❌ No .tex file found in last pipeline zip")
        _write_logs(LOG_PATH, logs)
        return "<div style='color:#b00'>No .tex found in last pipeline zip</div>", None

    # Ensure local fonts and theme precedence (same as other debug path)
    try:
        src_fonts = ROOT / "template" / "fonts"
        dst_fonts = work_zip_dir / "fonts"
        if src_fonts.exists():
            for root_dir, dirs, files in os.walk(src_fonts):
                rel = Path(root_dir).relative_to(src_fonts)
                out_dir = dst_fonts / rel
                out_dir.mkdir(parents=True, exist_ok=True)
                for fn in files:
                    if fn.lower().endswith((".ttf", ".otf")):
                        shutil.copy2(Path(root_dir)/fn, out_dir/fn)
            logs.append("📁 Copied local fonts → zip_last/fonts/")
        # Copy repository theme .sty next to tex and at root
        try:
            tmpl_dir = ROOT / "template"
            for sty in tmpl_dir.glob("*.sty"):
                shutil.copy2(sty, work_zip_dir / sty.name)
                shutil.copy2(sty, tex_path.parent / sty.name)
            logs.append("📄 Copied template/*.sty → zip_last/ and tex dir")
        except Exception as e:
            logs.append(f"⚠️ Copy sty failed: {e}")
    except Exception as e:
        logs.append(f"⚠️ Local font setup failed: {e}")

    # Compile to PDF
    pdf_path = _compile_tex_to_pdf(tex_path, logs)
    if not pdf_path or not pdf_path.exists():
        logs.append("❌ Failed to compile last pipeline zip PDF.")
        _write_logs(LOG_PATH, logs)
        return (
            "<div style='color:#b00'><b>Compile failed.</b></div>"
            + "<pre style='white-space:pre-wrap;background:#f7f7f8;padding:8px;border-radius:6px'>"
            + "\n".join(logs)
            + "</pre>",
            None,
        )
    try:
        b64 = base64.b64encode(pdf_path.read_bytes()).decode("utf-8")
        open_tab = f"<a target='_blank' rel='noopener' href='data:application/pdf;base64,{b64}'>Open PDF in new tab</a>"
        html = (
            f"<div style='margin-bottom:8px'>{open_tab}</div>"
            + _pdf_to_iframe_html(pdf_path, height="700px")
        )
        _write_logs(LOG_PATH, logs)
        return html, str(pdf_path)
    except Exception as e:
        logs.append(f"⚠️ preview failed: {e}")
        _write_logs(LOG_PATH, logs)
        return f"<div>Compiled but preview failed: {e}</div>", None

def debug_compile_uploaded_zip(zip_file):
    """Compile an uploaded poster zip (user-provided); return preview HTML + PDF path."""
    logs = [f"🐞 Debug(upload) at {_now_str()}"]
    if not zip_file:
        return "<div style='color:#b00'>Please upload a .zip file first.</div>", None
    # Prepare workspace
    run_id, WORK_DIR, LOG_PATH, _ = _prepare_workspace(logs)
    work_zip_dir = WORK_DIR / "zip_upload"
    work_zip_dir.mkdir(parents=True, exist_ok=True)
    # Save uploaded zip
    up_path = work_zip_dir / "input.zip"
    try:
        shutil.copy(zip_file.name, up_path)
    except Exception as e:
        logs.append(f"❌ save upload failed: {e}")
        _write_logs(LOG_PATH, logs)
        return "<div style='color:#b00'>Save upload failed.</div>", None
    # Extract
    try:
        import zipfile as _zf
        with _zf.ZipFile(up_path, 'r') as zf:
            zf.extractall(work_zip_dir)
    except Exception as e:
        logs.append(f"❌ unzip failed: {e}")
        _write_logs(LOG_PATH, logs)
        return "<div style='color:#b00'>Unzip failed.</div>", None
    # Find tex
    tex_path = None
    for name in ("poster_output.tex", "poster.tex"):
        cand = list(work_zip_dir.rglob(name))
        if cand:
            tex_path = cand[0]
            break
    if tex_path is None:
        cand = list(work_zip_dir.rglob("*.tex"))
        if cand:
            tex_path = cand[0]
    if tex_path is None:
        logs.append("❌ No .tex file found in uploaded zip")
        _write_logs(LOG_PATH, logs)
        return "<div style='color:#b00'>No .tex found in uploaded zip</div>"
    # Disable logoleft if missing
    try:
        logos_dir = tex_path.parent / "logos"
        has_left = False
        if logos_dir.exists():
            for p in logos_dir.iterdir():
                if p.is_file() and p.stem == "left_logo":
                    has_left = True
                    break
        if not has_left:
            txt = tex_path.read_text(encoding="utf-8")
            if "\\logoleft" in txt:
                import re as _re
                new_txt = _re.sub(r"^\\\s*logoleft\s*\{.*?\}\s*$", lambda m: "%" + m.group(0), txt, flags=_re.MULTILINE)
                if new_txt != txt:
                    tex_path.write_text(new_txt, encoding="utf-8")
                    logs.append("ℹ️ No left_logo found; disabled \\logoleft in uploaded project.")
    except Exception as e:
        logs.append(f"⚠️ left_logo adjust failed: {e}")
    # Compile
    pdf_path = _compile_tex_to_pdf(tex_path, logs)
    if not pdf_path or not pdf_path.exists():
        logs.append("❌ Failed to compile uploaded zip PDF.")
        _write_logs(LOG_PATH, logs)
        return (
            "<div style='color:#b00'><b>Compile failed.</b></div>"
            + "<pre style='white-space:pre-wrap;background:#f7f7f8;padding:8px;border-radius:6px'>"
            + "\n".join(logs)
            + "</pre>",
            None,
        )
    try:
        b64 = base64.b64encode(pdf_path.read_bytes()).decode("utf-8")
        open_tab = f"<a target='_blank' rel='noopener' href='data:application/pdf;base64,{b64}'>Open PDF in new tab</a>"
        html = (
            f"<div style='margin-bottom:8px'>{open_tab}</div>"
            + _pdf_to_iframe_html(pdf_path, height="700px")
        )
        _write_logs(LOG_PATH, logs)
        return "", str(pdf_path)
    except Exception as e:
        logs.append(f"⚠️ preview failed: {e}")
        _write_logs(LOG_PATH, logs)
        return f"<div>Compiled but preview failed: {e}</div>", None

def debug_compile_output_zip():
    """Compile the repo-root output.zip (a real LaTeX project) and preview the resulting PDF."""
    # Stage repo output.zip to runs/<id>/output.zip to follow pipeline layout, then delegate
    zip_path = ROOT / "output.zip"
    if not zip_path.exists():
        return (
            "<div style='color:#b00'><b>output.zip not found at repo root.</b></div>"
            + f"<div>Expected at: {zip_path}</div>",
            None,
        )
    logs = [f"🐞 Stage(repo zip) at {_now_str()}"]
    _, WORK_DIR, LOG_PATH, ZIP_PATH = _prepare_workspace(logs)
    try:
        shutil.copy2(zip_path, ZIP_PATH)
        logs.append(f"📦 Staged repo output.zip → runs/{WORK_DIR.name}/output.zip")
        _write_logs(LOG_PATH, logs)
    except Exception as e:
        logs.append(f"❌ Failed staging output.zip: {e}")
        _write_logs(LOG_PATH, logs)
        return "<div style='color:#b00'>Failed to stage output.zip</div>"
    return debug_compile_last_pipeline_zip()
    logs = [f"🐞 Debug(real) at {_now_str()}"]
    zip_path = ROOT / "output.zip"
    if not zip_path.exists():
        return (
            "<div style='color:#b00'><b>output.zip not found at repo root.</b></div>"
            + f"<div>Expected at: {zip_path}</div>",
            None,
        )

    # Prepare workspace
    run_id, WORK_DIR, LOG_PATH, _ = _prepare_workspace(logs)
    work_zip_dir = WORK_DIR / "zip_proj"
    work_zip_dir.mkdir(parents=True, exist_ok=True)
    logs.append(f"Workspace: runs/{WORK_DIR.name}")
    logs.append("Unzipping output.zip → zip_proj/")

    # Extract zip
    try:
        import zipfile as _zf
        with _zf.ZipFile(zip_path, 'r') as zf:
            zf.extractall(work_zip_dir)
    except Exception as e:
        logs.append(f"❌ unzip failed: {e}")
        _write_logs(LOG_PATH, logs)
        return "<div style='color:#b00'>Unzip failed.</div>", None

    # Locate poster_output.tex (fallback to poster.tex)
    tex_path = None
    for name in ("poster_output.tex", "poster.tex"):
        cand = list(work_zip_dir.rglob(name))
        if cand:
            tex_path = cand[0]
            break
    if tex_path is None:
        # fallback: any .tex
        cand = list(work_zip_dir.rglob("*.tex"))
        if cand:
            tex_path = cand[0]
    if tex_path is None:
        logs.append("❌ No .tex file found in output.zip")
        _write_logs(LOG_PATH, logs)
        return "<div style='color:#b00'>No .tex found in output.zip</div>", None

    # If left_logo missing, disable \logoleft
    try:
        logos_dir = tex_path.parent / "logos"
        has_left = False
        if logos_dir.exists():
            for p in logos_dir.iterdir():
                if p.is_file() and p.stem == "left_logo":
                    has_left = True
                    break
        if not has_left:
            txt = tex_path.read_text(encoding="utf-8")
            if "\\logoleft" in txt:
                import re as _re
                new_txt = _re.sub(r"^\\\s*logoleft\s*\{.*?\}\s*$", lambda m: "%" + m.group(0), txt, flags=_re.MULTILINE)
                if new_txt != txt:
                    tex_path.write_text(new_txt, encoding="utf-8")
                    logs.append("ℹ️ No left_logo found; disabled \\logoleft in zip project.")
    except Exception as e:
        logs.append(f"⚠️ left_logo adjust failed: {e}")

    # Compile to PDF
    pdf_path = _compile_tex_to_pdf(tex_path, logs)
    if not pdf_path or not pdf_path.exists():
        logs.append("❌ Failed to compile zip PDF.")
        _write_logs(LOG_PATH, logs)
        return (
            "<div style='color:#b00'><b>Compile failed.</b></div>"
            + "<pre style='white-space:pre-wrap;background:#f7f7f8;padding:8px;border-radius:6px'>"
            + "\n".join(logs)
            + "</pre>",
            None,
        )

    try:
        b64 = base64.b64encode(pdf_path.read_bytes()).decode("utf-8")
        open_tab = f"<a target='_blank' rel='noopener' href='data:application/pdf;base64,{b64}'>Open PDF in new tab</a>"
        html = (
            f"<div style='margin-bottom:8px'>{open_tab}</div>"
            + _pdf_to_iframe_html(pdf_path, height="700px")
        )
        _write_logs(LOG_PATH, logs)
        return html, str(pdf_path)
    except Exception as e:
        logs.append(f"⚠️ preview failed: {e}")
        _write_logs(LOG_PATH, logs)
        return f"<div>Compiled but preview failed: {e}</div>", None

def _find_last_pipeline_zip():
    try:
        candidates = []
        for d in RUNS_DIR.iterdir():
            try:
                if d.is_dir():
                    z = d / "output.zip"
                    if z.exists():
                        candidates.append((z.stat().st_mtime, z))
            except Exception:
                pass
        if not candidates:
            return None
        candidates.sort(key=lambda x: x[0], reverse=True)
        return candidates[0][1]
    except Exception:
        return None

def debug_compile_last_pipeline_zip():
    """Find the most recent runs/*/output.zip from pipeline, compile, and return preview HTML + PDF path."""
    logs = [f"🐞 Debug(last-pipeline-zip) at {_now_str()}"]
    last_zip = _find_last_pipeline_zip()
    if not last_zip:
        repo_zip = ROOT / "output.zip"
        if repo_zip.exists():
            try:
                _, W, L, Z = _prepare_workspace(logs)
                shutil.copy2(repo_zip, Z)
                logs.append(f"📦 Auto-staged repo output.zip → runs/{W.name}/output.zip")
                last_zip = Z
            except Exception as e:
                logs.append(f"❌ Auto-stage failed: {e}")
                return "<div style='color:#b00'>No recent pipeline output.zip found and auto-stage failed.</div>"
        else:
            return "<div style='color:#b00'>No recent pipeline output.zip found under runs/.</div>", None

    # Prepare workspace
    run_id, WORK_DIR, LOG_PATH, _ = _prepare_workspace(logs)
    work_zip_dir = WORK_DIR / "zip_last"
    work_zip_dir.mkdir(parents=True, exist_ok=True)
    logs.append(f"Workspace: runs/{WORK_DIR.name}")
    logs.append(f"Using: {last_zip}")

    # Extract zip
    try:
        import zipfile as _zf
        with _zf.ZipFile(last_zip, 'r') as zf:
            zf.extractall(work_zip_dir)
    except Exception as e:
        logs.append(f"❌ unzip failed: {e}")
        _write_logs(LOG_PATH, logs)
        return "<div style='color:#b00'>Unzip failed.</div>", None

    # Locate tex
    tex_path = None
    for name in ("poster_output.tex", "poster.tex"):
        cand = list(work_zip_dir.rglob(name))
        if cand:
            tex_path = cand[0]
            break
    if tex_path is None:
        cand = list(work_zip_dir.rglob("*.tex"))
        if cand:
            tex_path = cand[0]
    if tex_path is None:
        logs.append("❌ No .tex file found in last pipeline zip")
        _write_logs(LOG_PATH, logs)
        return "<div style='color:#b00'>No .tex found in last pipeline zip</div>", None

    # Ensure local fonts and theme precedence (same as other debug path)
    try:
        src_fonts = ROOT / "template" / "fonts"
        dst_fonts = work_zip_dir / "fonts"
        if src_fonts.exists():
            for root_dir, dirs, files in os.walk(src_fonts):
                rel = Path(root_dir).relative_to(src_fonts)
                out_dir = dst_fonts / rel
                out_dir.mkdir(parents=True, exist_ok=True)
                for fn in files:
                    if fn.lower().endswith((".ttf", ".otf")):
                        shutil.copy2(Path(root_dir)/fn, out_dir/fn)
            logs.append("📁 Copied local fonts → zip_last/fonts/")
        # Copy repository theme .sty next to tex and at root
        try:
            tmpl_dir = ROOT / "template"
            for sty in tmpl_dir.glob("*.sty"):
                shutil.copy2(sty, work_zip_dir / sty.name)
                shutil.copy2(sty, tex_path.parent / sty.name)
            logs.append("📄 Copied template/*.sty → zip_last/ and tex dir")
        except Exception as e:
            logs.append(f"⚠️ Copy sty failed: {e}")
    except Exception as e:
        logs.append(f"⚠️ Local font setup failed: {e}")

    # Compile to PDF
    pdf_path = _compile_tex_to_pdf(tex_path, logs)
    if not pdf_path or not pdf_path.exists():
        logs.append("❌ Failed to compile last pipeline zip PDF.")
        _write_logs(LOG_PATH, logs)
        return (
            "<div style='color:#b00'><b>Compile failed.</b></div>"
            + "<pre style='white-space:pre-wrap;background:#f7f7f8;padding:8px;border-radius:6px'>"
            + "\n".join(logs)
            + "</pre>",
            None,
        )
    try:
        b64 = base64.b64encode(pdf_path.read_bytes()).decode("utf-8")
        open_tab = f"<a target='_blank' rel='noopener' href='data:application/pdf;base64,{b64}'>Open PDF in new tab</a>"
        html = (
            f"<div style='margin-bottom:8px'>{open_tab}</div>"
            + _pdf_to_iframe_html(pdf_path, height="700px")
        )
        _write_logs(LOG_PATH, logs)
        return html, str(pdf_path)
    except Exception as e:
        logs.append(f"⚠️ preview failed: {e}")
        _write_logs(LOG_PATH, logs)
        return f"<div>Compiled but preview failed: {e}</div>", None

def debug_compile_uploaded_zip(zip_file):
    """Compile an uploaded poster zip (user-provided); return preview HTML + PDF path."""
    logs = [f"🐞 Debug(upload) at {_now_str()}"]
    if not zip_file:
        return "<div style='color:#b00'>Please upload a .zip file first.</div>", None
    # Prepare workspace
    run_id, WORK_DIR, LOG_PATH, _ = _prepare_workspace(logs)
    work_zip_dir = WORK_DIR / "zip_upload"
    work_zip_dir.mkdir(parents=True, exist_ok=True)
    # Save uploaded zip
    up_path = work_zip_dir / "input.zip"
    try:
        shutil.copy(zip_file.name, up_path)
    except Exception as e:
        logs.append(f"❌ save upload failed: {e}")
        _write_logs(LOG_PATH, logs)
        return "<div style='color:#b00'>Save upload failed.</div>", None
    # Extract
    try:
        import zipfile as _zf
        with _zf.ZipFile(up_path, 'r') as zf:
            zf.extractall(work_zip_dir)
    except Exception as e:
        logs.append(f"❌ unzip failed: {e}")
        _write_logs(LOG_PATH, logs)
        return "<div style='color:#b00'>Unzip failed.</div>", None
    # Find tex
    tex_path = None
    for name in ("poster_output.tex", "poster.tex"):
        cand = list(work_zip_dir.rglob(name))
        if cand:
            tex_path = cand[0]
            break
    if tex_path is None:
        cand = list(work_zip_dir.rglob("*.tex"))
        if cand:
            tex_path = cand[0]
    if tex_path is None:
        logs.append("❌ No .tex file found in uploaded zip")
        _write_logs(LOG_PATH, logs)
        return "<div style='color:#b00'>No .tex found in uploaded zip</div>"
    # Disable logoleft if missing
    try:
        logos_dir = tex_path.parent / "logos"
        has_left = False
        if logos_dir.exists():
            for p in logos_dir.iterdir():
                if p.is_file() and p.stem == "left_logo":
                    has_left = True
                    break
        if not has_left:
            txt = tex_path.read_text(encoding="utf-8")
            if "\\logoleft" in txt:
                import re as _re
                new_txt = _re.sub(r"^\\\s*logoleft\s*\{.*?\}\s*$", lambda m: "%" + m.group(0), txt, flags=_re.MULTILINE)
                if new_txt != txt:
                    tex_path.write_text(new_txt, encoding="utf-8")
                    logs.append("ℹ️ No left_logo found; disabled \\logoleft in uploaded project.")
    except Exception as e:
        logs.append(f"⚠️ left_logo adjust failed: {e}")
    # Compile
    pdf_path = _compile_tex_to_pdf(tex_path, logs)
    if not pdf_path or not pdf_path.exists():
        logs.append("❌ Failed to compile uploaded zip PDF.")
        _write_logs(LOG_PATH, logs)
        return (
            "<div style='color:#b00'><b>Compile failed.</b></div>"
            + "<pre style='white-space:pre-wrap;background:#f7f7f8;padding:8px;border-radius:6px'>"
            + "\n".join(logs)
            + "</pre>",
            None,
        )
    try:
        b64 = base64.b64encode(pdf_path.read_bytes()).decode("utf-8")
        open_tab = f"<a target='_blank' rel='noopener' href='data:application/pdf;base64,{b64}'>Open PDF in new tab</a>"
        html = (
            f"<div style='margin-bottom:8px'>{open_tab}</div>"
            + _pdf_to_iframe_html(pdf_path, height="700px")
        )
        _write_logs(LOG_PATH, logs)
        return "", str(pdf_path)
    except Exception as e:
        logs.append(f"⚠️ preview failed: {e}")
        _write_logs(LOG_PATH, logs)
        return f"<div>Compiled but preview failed: {e}</div>", None

def debug_compile_output_zip():
    """Compile the repo-root output.zip (a real LaTeX project) and preview the resulting PDF."""
    # Stage repo output.zip to runs/<id>/output.zip to follow pipeline layout, then delegate
    zip_path = ROOT / "output.zip"
    if not zip_path.exists():
        return (
            "<div style='color:#b00'><b>output.zip not found at repo root.</b></div>"
            + f"<div>Expected at: {zip_path}</div>",
            None,
        )
    logs = [f"🐞 Stage(repo zip) at {_now_str()}"]
    _, WORK_DIR, LOG_PATH, ZIP_PATH = _prepare_workspace(logs)
    try:
        shutil.copy2(zip_path, ZIP_PATH)
        logs.append(f"📦 Staged repo output.zip → runs/{WORK_DIR.name}/output.zip")
        _write_logs(LOG_PATH, logs)
    except Exception as e:
        logs.append(f"❌ Failed staging output.zip: {e}")
        _write_logs(LOG_PATH, logs)
        return "<div style='color:#b00'>Failed to stage output.zip</div>"
    return debug_compile_last_pipeline_zip()
    logs = [f"🐞 Debug(real) at {_now_str()}"]
    zip_path = ROOT / "output.zip"
    if not zip_path.exists():
        return (
            "<div style='color:#b00'><b>output.zip not found at repo root.</b></div>"
            + f"<div>Expected at: {zip_path}</div>",
            None,
        )

    # Prepare workspace
    run_id, WORK_DIR, LOG_PATH, _ = _prepare_workspace(logs)
    work_zip_dir = WORK_DIR / "zip_proj"
    work_zip_dir.mkdir(parents=True, exist_ok=True)
    logs.append(f"Workspace: runs/{WORK_DIR.name}")
    logs.append("Unzipping output.zip → zip_proj/")

    # Extract zip
    try:
        import zipfile as _zf
        with _zf.ZipFile(zip_path, 'r') as zf:
            zf.extractall(work_zip_dir)
    except Exception as e:
        logs.append(f"❌ unzip failed: {e}")
        _write_logs(LOG_PATH, logs)
        return "<div style='color:#b00'>Unzip failed.</div>", None

    # Locate poster_output.tex (fallback to poster.tex)
    tex_path = None
    for name in ("poster_output.tex", "poster.tex"):
        cand = list(work_zip_dir.rglob(name))
        if cand:
            tex_path = cand[0]
            break
    if tex_path is None:
        # fallback: any .tex
        cand = list(work_zip_dir.rglob("*.tex"))
        if cand:
            tex_path = cand[0]
    if tex_path is None:
        logs.append("❌ No .tex file found in output.zip")
        _write_logs(LOG_PATH, logs)
        return "<div style='color:#b00'>No .tex found in output.zip</div>", None

    # If left_logo missing, disable \logoleft
    try:
        logos_dir = tex_path.parent / "logos"
        has_left = False
        if logos_dir.exists():
            for p in logos_dir.iterdir():
                if p.is_file() and p.stem == "left_logo":
                    has_left = True
                    break
        if not has_left:
            txt = tex_path.read_text(encoding="utf-8")
            if "\\logoleft" in txt:
                import re as _re
                new_txt = _re.sub(r"^\\\s*logoleft\s*\{.*?\}\s*$", lambda m: "%" + m.group(0), txt, flags=_re.MULTILINE)
                if new_txt != txt:
                    tex_path.write_text(new_txt, encoding="utf-8")
                    logs.append("ℹ️ No left_logo found; disabled \\logoleft in zip project.")
    except Exception as e:
        logs.append(f"⚠️ left_logo adjust failed: {e}")

    # Compile to PDF
    pdf_path = _compile_tex_to_pdf(tex_path, logs)
    if not pdf_path or not pdf_path.exists():
        logs.append("❌ Failed to compile zip PDF.")
        _write_logs(LOG_PATH, logs)
        return (
            "<div style='color:#b00'><b>Compile failed.</b></div>"
            + "<pre style='white-space:pre-wrap;background:#f7f7f8;padding:8px;border-radius:6px'>"
            + "\n".join(logs)
            + "</pre>",
            None,
        )

    try:
        b64 = base64.b64encode(pdf_path.read_bytes()).decode("utf-8")
        open_tab = f"<a target='_blank' rel='noopener' href='data:application/pdf;base64,{b64}'>Open PDF in new tab</a>"
        html = (
            f"<div style='margin-bottom:8px'>{open_tab}</div>"
            + _pdf_to_iframe_html(pdf_path, height="700px")
        )
        _write_logs(LOG_PATH, logs)
        return html, str(pdf_path)
    except Exception as e:
        logs.append(f"⚠️ preview failed: {e}")
        _write_logs(LOG_PATH, logs)
        return f"<div>Compiled but preview failed: {e}</div>", None

def _find_last_pipeline_zip():
    try:
        candidates = []
        for d in RUNS_DIR.iterdir():
            try:
                if d.is_dir():
                    z = d / "output.zip"
                    if z.exists():
                        candidates.append((z.stat().st_mtime, z))
            except Exception:
                pass
        if not candidates:
            return None
        candidates.sort(key=lambda x: x[0], reverse=True)
        return candidates[0][1]
    except Exception:
        return None

def debug_compile_last_pipeline_zip():
    """Find the most recent runs/*/output.zip from pipeline, compile, and return preview HTML + PDF path."""
    logs = [f"🐞 Debug(last-pipeline-zip) at {_now_str()}"]
    last_zip = _find_last_pipeline_zip()
    if not last_zip:
        repo_zip = ROOT / "output.zip"
        if repo_zip.exists():
            try:
                _, W, L, Z = _prepare_workspace(logs)
                shutil.copy2(repo_zip, Z)
                logs.append(f"📦 Auto-staged repo output.zip → runs/{W.name}/output.zip")
                last_zip = Z
            except Exception as e:
                logs.append(f"❌ Auto-stage failed: {e}")
                return "<div style='color:#b00'>No recent pipeline output.zip found and auto-stage failed.</div>"
        else:
            return "<div style='color:#b00'>No recent pipeline output.zip found under runs/.</div>", None

    # Prepare workspace
    run_id, WORK_DIR, LOG_PATH, _ = _prepare_workspace(logs)
    work_zip_dir = WORK_DIR / "zip_last"
    work_zip_dir.mkdir(parents=True, exist_ok=True)
    logs.append(f"Workspace: runs/{WORK_DIR.name}")
    logs.append(f"Using: {last_zip}")

    # Extract zip
    try:
        import zipfile as _zf
        with _zf.ZipFile(last_zip, 'r') as zf:
            zf.extractall(work_zip_dir)
    except Exception as e:
        logs.append(f"❌ unzip failed: {e}")
        _write_logs(LOG_PATH, logs)
        return "<div style='color:#b00'>Unzip failed.</div>", None

    # Locate tex
    tex_path = None
    for name in ("poster_output.tex", "poster.tex"):
        cand = list(work_zip_dir.rglob(name))
        if cand:
            tex_path = cand[0]
            break
    if tex_path is None:
        cand = list(work_zip_dir.rglob("*.tex"))
        if cand:
            tex_path = cand[0]
    if tex_path is None:
        logs.append("❌ No .tex file found in last pipeline zip")
        _write_logs(LOG_PATH, logs)
        return "<div style='color:#b00'>No .tex found in last pipeline zip</div>", None

    # Ensure local fonts and theme precedence (same as other debug path)
    try:
        src_fonts = ROOT / "template" / "fonts"
        dst_fonts = work_zip_dir / "fonts"
        if src_fonts.exists():
            for root_dir, dirs, files in os.walk(src_fonts):
                rel = Path(root_dir).relative_to(src_fonts)
                out_dir = dst_fonts / rel
                out_dir.mkdir(parents=True, exist_ok=True)
                for fn in files:
                    if fn.lower().endswith((".ttf", ".otf")):
                        shutil.copy2(Path(root_dir)/fn, out_dir/fn)
            logs.append("📁 Copied local fonts → zip_last/fonts/")
        # Copy repository theme .sty next to tex and at root
        try:
            tmpl_dir = ROOT / "template"
            for sty in tmpl_dir.glob("*.sty"):
                shutil.copy2(sty, work_zip_dir / sty.name)
                shutil.copy2(sty, tex_path.parent / sty.name)
            logs.append("📄 Copied template/*.sty → zip_last/ and tex dir")
        except Exception as e:
            logs.append(f"⚠️ Copy sty failed: {e}")
    except Exception as e:
        logs.append(f"⚠️ Local font setup failed: {e}")

    # Compile to PDF
    pdf_path = _compile_tex_to_pdf(tex_path, logs)
    if not pdf_path or not pdf_path.exists():
        logs.append("❌ Failed to compile last pipeline zip PDF.")
        _write_logs(LOG_PATH, logs)
        return (
            "<div style='color:#b00'><b>Compile failed.</b></div>"
            + "<pre style='white-space:pre-wrap;background:#f7f7f8;padding:8px;border-radius:6px'>"
            + "\n".join(logs)
            + "</pre>",
            None,
        )
    try:
        b64 = base64.b64encode(pdf_path.read_bytes()).decode("utf-8")
        open_tab = f"<a target='_blank' rel='noopener' href='data:application/pdf;base64,{b64}'>Open PDF in new tab</a>"
        html = (
            f"<div style='margin-bottom:8px'>{open_tab}</div>"
            + _pdf_to_iframe_html(pdf_path, height="700px")
        )
        _write_logs(LOG_PATH, logs)
        return html, str(pdf_path)
    except Exception as e:
        logs.append(f"⚠️ preview failed: {e}")
        _write_logs(LOG_PATH, logs)
        return f"<div>Compiled but preview failed: {e}</div>", None

def debug_compile_uploaded_zip(zip_file):
    """Compile an uploaded poster zip (user-provided); return preview HTML + PDF path."""
    logs = [f"🐞 Debug(upload) at {_now_str()}"]
    if not zip_file:
        return "<div style='color:#b00'>Please upload a .zip file first.</div>", None
    # Prepare workspace
    run_id, WORK_DIR, LOG_PATH, _ = _prepare_workspace(logs)
    work_zip_dir = WORK_DIR / "zip_upload"
    work_zip_dir.mkdir(parents=True, exist_ok=True)
    # Save uploaded zip
    up_path = work_zip_dir / "input.zip"
    try:
        shutil.copy(zip_file.name, up_path)
    except Exception as e:
        logs.append(f"❌ save upload failed: {e}")
        _write_logs(LOG_PATH, logs)
        return "<div style='color:#b00'>Save upload failed.</div>", None
    # Extract
    try:
        import zipfile as _zf
        with _zf.ZipFile(up_path, 'r') as zf:
            zf.extractall(work_zip_dir)
    except Exception as e:
        logs.append(f"❌ unzip failed: {e}")
        _write_logs(LOG_PATH, logs)
        return "<div style='color:#b00'>Unzip failed.</div>", None
    # Find tex
    tex_path = None
    for name in ("poster_output.tex", "poster.tex"):
        cand = list(work_zip_dir.rglob(name))
        if cand:
            tex_path = cand[0]
            break
    if tex_path is None:
        cand = list(work_zip_dir.rglob("*.tex"))
        if cand:
            tex_path = cand[0]
    if tex_path is None:
        logs.append("❌ No .tex file found in uploaded zip")
        _write_logs(LOG_PATH, logs)
        return "<div style='color:#b00'>No .tex found in uploaded zip</div>"
    # Disable logoleft if missing
    try:
        logos_dir = tex_path.parent / "logos"
        has_left = False
        if logos_dir.exists():
            for p in logos_dir.iterdir():
                if p.is_file() and p.stem == "left_logo":
                    has_left = True
                    break
        if not has_left:
            txt = tex_path.read_text(encoding="utf-8")
            if "\\logoleft" in txt:
                import re as _re
                new_txt = _re.sub(r"^\\\s*logoleft\s*\{.*?\}\s*$", lambda m: "%" + m.group(0), txt, flags=_re.MULTILINE)
                if new_txt != txt:
                    tex_path.write_text(new_txt, encoding="utf-8")
                    logs.append("ℹ️ No left_logo found; disabled \\logoleft in uploaded project.")
    except Exception as e:
        logs.append(f"⚠️ left_logo adjust failed: {e}")
    # Compile
    pdf_path = _compile_tex_to_pdf(tex_path, logs)
    if not pdf_path or not pdf_path.exists():
        logs.append("❌ Failed to compile uploaded zip PDF.")
        _write_logs(LOG_PATH, logs)
        return (
            "<div style='color:#b00'><b>Compile failed.</b></div>"
            + "<pre style='white-space:pre-wrap;background:#f7f7f8;padding:8px;border-radius:6px'>"
            + "\n".join(logs)
            + "</pre>",
            None,
        )
    try:
        b64 = base64.b64encode(pdf_path.read_bytes()).decode("utf-8")
        open_tab = f"<a target='_blank' rel='noopener' href='data:application/pdf;base64,{b64}'>Open PDF in new tab</a>"
        html = (
            f"<div style='margin-bottom:8px'>{open_tab}</div>"
            + _pdf_to_iframe_html(pdf_path, height="700px")
        )
        _write_logs(LOG_PATH, logs)
        return "", str(pdf_path)
    except Exception as e:
        logs.append(f"⚠️ preview failed: {e}")
        _write_logs(LOG_PATH, logs)
        return f"<div>Compiled but preview failed: {e}</div>", None

def debug_compile_output_zip():
    """Compile the repo-root output.zip (a real LaTeX project) and preview the resulting PDF."""
    # Stage repo output.zip to runs/<id>/output.zip to follow pipeline layout, then delegate
    zip_path = ROOT / "output.zip"
    if not zip_path.exists():
        return (
            "<div style='color:#b00'><b>output.zip not found at repo root.</b></div>"
            + f"<div>Expected at: {zip_path}</div>",
            None,
        )
    logs = [f"🐞 Stage(repo zip) at {_now_str()}"]
    _, WORK_DIR, LOG_PATH, ZIP_PATH = _prepare_workspace(logs)
    try:
        shutil.copy2(zip_path, ZIP_PATH)
        logs.append(f"📦 Staged repo output.zip → runs/{WORK_DIR.name}/output.zip")
        _write_logs(LOG_PATH, logs)
    except Exception as e:
        logs.append(f"❌ Failed staging output.zip: {e}")
        _write_logs(LOG_PATH, logs)
        return "<div style='color:#b00'>Failed to stage output.zip</div>"
    return debug_compile_last_pipeline_zip()
    logs = [f"🐞 Debug(real) at {_now_str()}"]
    zip_path = ROOT / "output.zip"
    if not zip_path.exists():
        return (
            "<div style='color:#b00'><b>output.zip not found at repo root.</b></div>"
            + f"<div>Expected at: {zip_path}</div>"
        )

    # Prepare workspace
    run_id, WORK_DIR, LOG_PATH, _ = _prepare_workspace(logs)
    work_zip_dir = WORK_DIR / "zip_proj"
    work_zip_dir.mkdir(parents=True, exist_ok=True)
    logs.append(f"Workspace: runs/{WORK_DIR.name}")
    logs.append("Unzipping output.zip → zip_proj/")

    # Extract zip
    try:
        import zipfile as _zf
        with _zf.ZipFile(zip_path, 'r') as zf:
            zf.extractall(work_zip_dir)
    except Exception as e:
        logs.append(f"❌ unzip failed: {e}")
        _write_logs(LOG_PATH, logs)
        return "<div style='color:#b00'>Unzip failed.</div>", None

    # Locate poster_output.tex (fallback to poster.tex)
    tex_path = None
    for name in ("poster_output.tex", "poster.tex"):
        cand = list(work_zip_dir.rglob(name))
        if cand:
            tex_path = cand[0]
            break
    if tex_path is None:
        # fallback: any .tex
        cand = list(work_zip_dir.rglob("*.tex"))
        if cand:
            tex_path = cand[0]
    if tex_path is None:
        logs.append("❌ No .tex file found in output.zip")
        _write_logs(LOG_PATH, logs)
        return "<div style='color:#b00'>No .tex found in output.zip</div>", None

    # If left_logo missing, disable \logoleft
    try:
        logos_dir = tex_path.parent / "logos"
        has_left = False
        if logos_dir.exists():
            for p in logos_dir.iterdir():
                if p.is_file() and p.stem == "left_logo":
                    has_left = True
                    break
        if not has_left:
            txt = tex_path.read_text(encoding="utf-8")
            if "\\logoleft" in txt:
                import re as _re
                new_txt = _re.sub(r"^\\\s*logoleft\s*\{.*?\}\s*$", lambda m: "%" + m.group(0), txt, flags=_re.MULTILINE)
                if new_txt != txt:
                    tex_path.write_text(new_txt, encoding="utf-8")
                    logs.append("ℹ️ No left_logo found; disabled \\logoleft in zip project.")
    except Exception as e:
        logs.append(f"⚠️ left_logo adjust failed: {e}")

    # Compile to PDF
    pdf_path = _compile_tex_to_pdf(tex_path, logs)
    if not pdf_path or not pdf_path.exists():
        logs.append("❌ Failed to compile zip PDF.")
        _write_logs(LOG_PATH, logs)
        return (
            "<div style='color:#b00'><b>Compile failed.</b></div>"
            + "<pre style='white-space:pre-wrap;background:#f7f7f8;padding:8px;border-radius:6px'>"
            + "\n".join(logs)
            + "</pre>",
            None,
        )

    try:
        b64 = base64.b64encode(pdf_path.read_bytes()).decode("utf-8")
        open_tab = f"<a target='_blank' rel='noopener' href='data:application/pdf;base64,{b64}'>Open PDF in new tab</a>"
        html = (
            f"<div style='margin-bottom:8px'>{open_tab}</div>"
            + _pdf_to_iframe_html(pdf_path, height="700px")
        )
        _write_logs(LOG_PATH, logs)
        return html, str(pdf_path)
    except Exception as e:
        logs.append(f"⚠️ preview failed: {e}")
        _write_logs(LOG_PATH, logs)
        return f"<div>Compiled but preview failed: {e}</div>", None

def _find_last_pipeline_zip():
    try:
        candidates = []
        for d in RUNS_DIR.iterdir():
            try:
                if d.is_dir():
                    z = d / "output.zip"
                    if z.exists():
                        candidates.append((z.stat().st_mtime, z))
            except Exception:
                pass
        if not candidates:
            return None
        candidates.sort(key=lambda x: x[0], reverse=True)
        return candidates[0][1]
    except Exception:
        return None

def debug_compile_last_pipeline_zip():
    """Find the most recent runs/*/output.zip from pipeline, compile, and return preview HTML + PDF path."""
    logs = [f"🐞 Debug(last-pipeline-zip) at {_now_str()}"]
    last_zip = _find_last_pipeline_zip()
    if not last_zip:
        repo_zip = ROOT / "output.zip"
        if repo_zip.exists():
            try:
                _, W, L, Z = _prepare_workspace(logs)
                shutil.copy2(repo_zip, Z)
                logs.append(f"📦 Auto-staged repo output.zip → runs/{W.name}/output.zip")
                last_zip = Z
            except Exception as e:
                logs.append(f"❌ Auto-stage failed: {e}")
                return "<div style='color:#b00'>No recent pipeline output.zip found and auto-stage failed.</div>"
        else:
            return "<div style='color:#b00'>No recent pipeline output.zip found under runs/.</div>", None

    # Prepare workspace
    run_id, WORK_DIR, LOG_PATH, _ = _prepare_workspace(logs)
    work_zip_dir = WORK_DIR / "zip_last"
    work_zip_dir.mkdir(parents=True, exist_ok=True)
    logs.append(f"Workspace: runs/{WORK_DIR.name}")
    logs.append(f"Using: {last_zip}")

    # Extract zip
    try:
        import zipfile as _zf
        with _zf.ZipFile(last_zip, 'r') as zf:
            zf.extractall(work_zip_dir)
    except Exception as e:
        logs.append(f"❌ unzip failed: {e}")
        _write_logs(LOG_PATH, logs)
        return "<div style='color:#b00'>Unzip failed.</div>", None

    # Locate tex
    tex_path = None
    for name in ("poster_output.tex", "poster.tex"):
        cand = list(work_zip_dir.rglob(name))
        if cand:
            tex_path = cand[0]
            break
    if tex_path is None:
        cand = list(work_zip_dir.rglob("*.tex"))
        if cand:
            tex_path = cand[0]
    if tex_path is None:
        logs.append("❌ No .tex file found in last pipeline zip")
        _write_logs(LOG_PATH, logs)
        return "<div style='color:#b00'>No .tex found in last pipeline zip</div>", None

    # Ensure local fonts and theme precedence (same as other debug path)
    try:
        src_fonts = ROOT / "template" / "fonts"
        dst_fonts = work_zip_dir / "fonts"
        if src_fonts.exists():
            for root_dir, dirs, files in os.walk(src_fonts):
                rel = Path(root_dir).relative_to(src_fonts)
                out_dir = dst_fonts / rel
                out_dir.mkdir(parents=True, exist_ok=True)
                for fn in files:
                    if fn.lower().endswith((".ttf", ".otf")):
                        shutil.copy2(Path(root_dir)/fn, out_dir/fn)
            logs.append("📁 Copied local fonts → zip_last/fonts/")
        # Copy repository theme .sty next to tex and at root
        try:
            tmpl_dir = ROOT / "template"
            for sty in tmpl_dir.glob("*.sty"):
                shutil.copy2(sty, work_zip_dir / sty.name)
                shutil.copy2(sty, tex_path.parent / sty.name)
            logs.append("📄 Copied template/*.sty → zip_last/ and tex dir")
        except Exception as e:
            logs.append(f"⚠️ Copy sty failed: {e}")
    except Exception as e:
        logs.append(f"⚠️ Local font setup failed: {e}")

    # Compile to PDF
    pdf_path = _compile_tex_to_pdf(tex_path, logs)
    if not pdf_path or not pdf_path.exists():
        logs.append("❌ Failed to compile last pipeline zip PDF.")
        _write_logs(LOG_PATH, logs)
        return (
            "<div style='color:#b00'><b>Compile failed.</b></div>"
            + "<pre style='white-space:pre-wrap;background:#f7f7f8;padding:8px;border-radius:6px'>"
            + "\n".join(logs)
            + "</pre>",
            None,
        )
    try:
        b64 = base64.b64encode(pdf_path.read_bytes()).decode("utf-8")
        open_tab = f"<a target='_blank' rel='noopener' href='data:application/pdf;base64,{b64}'>Open PDF in new tab</a>"
        html = (
            f"<div style='margin-bottom:8px'>{open_tab}</div>"
            + _pdf_to_iframe_html(pdf_path, height="700px")
        )
        _write_logs(LOG_PATH, logs)
        return html, str(pdf_path)
    except Exception as e:
        logs.append(f"⚠️ preview failed: {e}")
        _write_logs(LOG_PATH, logs)
        return f"<div>Compiled but preview failed: {e}</div>", None

def debug_compile_uploaded_zip(zip_file):
    """Compile an uploaded poster zip (user-provided); return preview HTML + PDF path."""
    logs = [f"🐞 Debug(upload) at {_now_str()}"]
    if not zip_file:
        return "<div style='color:#b00'>Please upload a .zip file first.</div>", None
    # Prepare workspace
    run_id, WORK_DIR, LOG_PATH, _ = _prepare_workspace(logs)
    work_zip_dir = WORK_DIR / "zip_upload"
    work_zip_dir.mkdir(parents=True, exist_ok=True)
    # Save uploaded zip
    up_path = work_zip_dir / "input.zip"
    try:
        shutil.copy(zip_file.name, up_path)
    except Exception as e:
        logs.append(f"❌ save upload failed: {e}")
        _write_logs(LOG_PATH, logs)
        return "<div style='color:#b00'>Save upload failed.</div>", None
    # Extract
    try:
        import zipfile as _zf
        with _zf.ZipFile(up_path, 'r') as zf:
            zf.extractall(work_zip_dir)
    except Exception as e:
        logs.append(f"❌ unzip failed: {e}")
        _write_logs(LOG_PATH, logs)
        return "<div style='color:#b00'>Unzip failed.</div>", None
    # Find tex
    tex_path = None
    for name in ("poster_output.tex", "poster.tex"):
        cand = list(work_zip_dir.rglob(name))
        if cand:
            tex_path = cand[0]
            break
    if tex_path is None:
        cand = list(work_zip_dir.rglob("*.tex"))
        if cand:
            tex_path = cand[0]
    if tex_path is None:
        logs.append("❌ No .tex file found in uploaded zip")
        _write_logs(LOG_PATH, logs)
        return "<div style='color:#b00'>No .tex found in uploaded zip</div>"
    # Disable logoleft if missing
    try:
        logos_dir = tex_path.parent / "logos"
        has_left = False
        if logos_dir.exists():
            for p in logos_dir.iterdir():
                if p.is_file() and p.stem == "left_logo":
                    has_left = True
                    break
        if not has_left:
            txt = tex_path.read_text(encoding="utf-8")
            if "\\logoleft" in txt:
                import re as _re
                new_txt = _re.sub(r"^\\\s*logoleft\s*\{.*?\}\s*$", lambda m: "%" + m.group(0), txt, flags=_re.MULTILINE)
                if new_txt != txt:
                    tex_path.write_text(new_txt, encoding="utf-8")
                    logs.append("ℹ️ No left_logo found; disabled \\logoleft in uploaded project.")
    except Exception as e:
        logs.append(f"⚠️ left_logo adjust failed: {e}")
    # Compile
    pdf_path = _compile_tex_to_pdf(tex_path, logs)
    if not pdf_path or not pdf_path.exists():
        logs.append("❌ Failed to compile uploaded zip PDF.")
        _write_logs(LOG_PATH, logs)
        return (
            "<div style='color:#b00'><b>Compile failed.</b></div>"
            + "<pre style='white-space:pre-wrap;background:#f7f7f8;padding:8px;border-radius:6px'>"
            + "\n".join(logs)
            + "</pre>",
            None,
        )
    try:
        b64 = base64.b64encode(pdf_path.read_bytes()).decode("utf-8")
        open_tab = f"<a target='_blank' rel='noopener' href='data:application/pdf;base64,{b64}'>Open PDF in new tab</a>"
        html = (
            f"<div style='margin-bottom:8px'>{open_tab}</div>"
            + _pdf_to_iframe_html(pdf_path, height="700px")
        )
        _write_logs(LOG_PATH, logs)
        return "", str(pdf_path)
    except Exception as e:
        logs.append(f"⚠️ preview failed: {e}")
        _write_logs(LOG_PATH, logs)
        return f"<div>Compiled but preview failed: {e}</div>", None

# =====================
# Gradio pipeline function (ISOLATED)
# =====================
def run_pipeline(arxiv_url, pdf_file, openai_key, logo_files, meeting_logo_file, theme_rgb):
    _cleanup_old_runs(RETENTION_HOURS)

    start_time = datetime.datetime.now()
    logs = [f"🚀 Starting pipeline at {_now_str()}"]

    # --- Prepare per-run workspace ---
    run_id, WORK_DIR, LOG_PATH, ZIP_PATH = _prepare_workspace(logs)
    INPUT_DIR = WORK_DIR / "input"
    OUTPUT_DIR = WORK_DIR / "output"
    LOGO_DIR = INPUT_DIR / "logo"
    POSTER_LATEX_DIR = WORK_DIR / "posterbuilder" / "latex_proj"

    _write_logs(LOG_PATH, logs)
    yield "\n".join(logs), "", None, None, ""

    # ====== Validation: must upload LOGO ======
    if logo_files is None:
        logo_files = []
    if not isinstance(logo_files, (list, tuple)):
        logo_files = [logo_files]
    logo_files = [f for f in logo_files if f]

    # if len(logo_files) == 0:
    #     msg = "❌ You must upload at least one institutional logo (multiple allowed)."
    #     logs.append(msg)
    #     _write_logs(LOG_PATH, logs)
    #     yield "\n".join(logs), "", None, ""
    #     return

    # Save logos into run-local dir
    for item in LOGO_DIR.iterdir():
        if item.is_file():
            item.unlink()
    saved_logo_paths = []
    for lf in logo_files:
        p = LOGO_DIR / Path(lf.name).name
        shutil.copy(lf.name, p)
        saved_logo_paths.append(p)
    logs.append(f"🏷️ Saved {len(saved_logo_paths)} logo file(s) → {LOGO_DIR.relative_to(WORK_DIR)}")
    _write_logs(LOG_PATH, logs)
    yield "\n".join(logs), "", None, None, ""

    # ====== Handle uploaded PDF (optional) ======
    pdf_path = None
    if pdf_file:
        pdf_dir = INPUT_DIR / "pdf"
        pdf_dir.mkdir(parents=True, exist_ok=True)
        pdf_path = pdf_dir / Path(pdf_file.name).name
        shutil.copy(pdf_file.name, pdf_path)
        logs.append(f"📄 Uploaded PDF → {pdf_path.relative_to(WORK_DIR)}")

        # For pipeline Step 1.5 compatibility: also copy to input/paper.pdf
        canonical_pdf = INPUT_DIR / "paper.pdf"
        shutil.copy(pdf_file.name, canonical_pdf)
        _write_logs(LOG_PATH, logs)
        yield "\n".join(logs), "", None, None, ""

    # ====== Validate input source ======
    if not arxiv_url and not pdf_file:
        msg = "❌ Please provide either an arXiv link or upload a PDF file (choose one)."
        logs.append(msg)
        _write_logs(LOG_PATH, logs)
        yield "\n".join(logs), "", None, None, ""
        return

    # ====== Build command (run INSIDE workspace) ======
    cmd = [
        sys.executable, "pipeline.py",
        "--model_name_t", "gpt-5",
        "--model_name_v", "gpt-5",
        "--result_dir", "output",
        "--paper_latex_root", "input/latex_proj",
        "--openai_key", openai_key,
        "--gemini_key", "##",
        "--logo_dir", str(LOGO_DIR)  # run-local logo dir
    ]
    if arxiv_url:
        cmd += ["--arxiv_url", arxiv_url]
    # (Keep pdf via input/paper.pdf; pipeline will read it if exists)

    logs.append("\n======= REAL-TIME LOG =======")
    logs.append(f"cwd = runs/{WORK_DIR.name}")
    _write_logs(LOG_PATH, logs)
    yield "\n".join(logs), "", None, None, ""

    # ====== Run with REAL-TIME streaming, inside workspace ======
    try:
        process = subprocess.Popen(
            cmd,
            cwd=str(WORK_DIR),
            stdout=subprocess.PIPE,
            stderr=subprocess.STDOUT,
            text=True,
            bufsize=1,
            universal_newlines=True,
        )
    except Exception as e:
        msg = f"❌ Pipeline failed to start: {e}"
        logs.append(msg)
        _write_logs(LOG_PATH, logs)
        yield "\n".join(logs), "", None, None, ""
        return

    last_yield = time.time()
    try:
        while True:
            # Timeout guard
            if (datetime.datetime.now() - start_time).total_seconds() > TIMEOUT_SECONDS:
                logs.append("❌ Pipeline timed out (30 min limit). Killing process…")
                try:
                    process.kill()
                except Exception:
                    pass
                _write_logs(LOG_PATH, logs)
                yield "\n".join(logs), "", None, None, ""
                return

            line = process.stdout.readline()
            if line:
                print(line, end="")  # echo to Space logs
                logs.append(line.rstrip("\n"))
                _write_logs(LOG_PATH, logs)
                now = time.time()
                if now - last_yield >= 0.3:
                    last_yield = now
                    yield "\n".join(logs), "", None, None, ""
            elif process.poll() is not None:
                break
            else:
                time.sleep(0.05)

        return_code = process.wait()
        logs.append(f"\nProcess finished with code {return_code}")
        _write_logs(LOG_PATH, logs)
        yield "\n".join(logs), "", None, None, ""

        if return_code != 0:
            logs.append("❌ Process exited with non-zero status. See logs above.")
            _write_logs(LOG_PATH, logs)
            yield "\n".join(logs), "", None, None, ""
            return

    except Exception as e:
        logs.append(f"❌ Error during streaming: {e}")
        _write_logs(LOG_PATH, logs)
        yield "\n".join(logs), "", None, None, ""
        return
    finally:
        try:
            if process.stdout:
                process.stdout.close()
        except Exception:
            pass

    # ====== Check output ======
    has_output = False
    try:
        if OUTPUT_DIR.exists():
            for _ in OUTPUT_DIR.iterdir():
                has_output = True
                break
    except FileNotFoundError:
        has_output = False

    if not has_output:
        msg = "❌ No output generated. Please check logs above."
        logs.append(msg)
        _write_logs(LOG_PATH, logs)
        yield "\n".join(logs), "", None, None, ""
        return

    # ====== NEW: Post-processing (optional features) ======
    # 1) Optional meeting logo replacement; if not provided, apply default NeurIPS
    applied_logo = _apply_meeting_logo(OUTPUT_DIR, meeting_logo_file, logs)
    if not applied_logo:
        _ensure_right_logo_default(OUTPUT_DIR, logs)

    # 2) Optional theme color update
    rgb_tuple = _parse_rgb(theme_rgb)
    if theme_rgb and not rgb_tuple:
        logs.append(f"⚠️ Ignored Theme RGB input '{theme_rgb}': expected like '94,46,145'.")
    applied_rgb = _apply_theme_rgb(OUTPUT_DIR, rgb_tuple, logs) if rgb_tuple else False

    # 3) Optional institutional logo -> left_logo.<ext>
    _apply_left_logo(OUTPUT_DIR, logo_files, logs)
    _ensure_left_logo_or_disable(OUTPUT_DIR, logs)

    _write_logs(LOG_PATH, logs)
    yield "\n".join(logs), "", None, None, ""


    _write_logs(LOG_PATH, logs)
    yield "\n".join(logs), "", None, None, ""

    # ====== Compile PDF (for download + image preview) ======
    pdf_html = ""
    compiled_pdf_file = None
    try:
        pdf_path = _compile_poster_pdf(OUTPUT_DIR, logs)
        if pdf_path and pdf_path.exists():
            # Prefer file-served iframe to avoid large data: URIs and browser blocks
            pdf_html = _pdf_to_iframe_file(pdf_path)
            compiled_pdf_file = str(pdf_path)
            logs.append("🖨️ PDF compiled (image preview available).")
    except Exception as e:
        logs.append(f"⚠️ PDF compile skipped: {e}")

    # ====== Zip output (run-local) ======
    try:
        target_dir = OUTPUT_DIR / "poster_latex_proj"

        if not target_dir.exists():
            logs.append("❌ poster_latex_proj folder not found")
        else:
            with zipfile.ZipFile(ZIP_PATH, 'w', zipfile.ZIP_DEFLATED) as zipf:
                for root, dirs, files in os.walk(target_dir):
                    for file in files:
                        file_path = Path(root) / file
                        arcname = file_path.relative_to(target_dir)  # only relative to subfolder
                        zipf.write(file_path, arcname=arcname)

            logs.append(f"✅ Zipped poster_latex_proj → {ZIP_PATH.relative_to(WORK_DIR)}")

    except Exception as e:
        logs.append(f"❌ Failed to create zip: {e}")

    # ====== Prepare Overleaf base64 payload (optional) ======
    overleaf_zip_b64 = ""
    try:
        with open(ZIP_PATH, "rb") as f:
            overleaf_zip_b64 = base64.b64encode(f.read()).decode("utf-8")
        logs.append("🔗 Prepared Overleaf base64 payload")
    except Exception as e:
        logs.append(f"⚠️ Failed Overleaf payload: {e}")

    end_time = datetime.datetime.now()
    dur = (end_time - start_time).seconds
    logs.append(f"🏁 Completed at {_now_str()} (Duration: {dur}s)")
    logs.append(f"🆔 run_id = {WORK_DIR.name}")

    _write_logs(LOG_PATH, logs)
    yield "\n".join(logs), (
        pdf_html
    ), (
        compiled_pdf_file
    ), (
        str(ZIP_PATH) if ZIP_PATH.exists() else None
    ), render_overleaf_button(overleaf_zip_b64)


def debug_compile():
    # Minimal debug disabled to simplify UI.
    return "<div style='color:#555'>Minimal debug disabled. Use 'Test repo output.zip' or 'Test last pipeline zip'.</div>"


# =====================
# Gradio UI
# =====================
with gr.Blocks(title="🎓 Paper2Poster") as iface:
    # Title
    gr.Markdown("# 🎓 Paper2Poster")
    gr.Markdown("""
[Paper](https://arxiv.org/abs/2505.21497) | [GitHub](https://github.com/Paper2Poster/Paper2Poster) | [Project Page](https://paper2poster.github.io/)  

**TL;DR:** Upload your paper and get an auto-generated poster.
Please be patient — each paper takes about 8–10 minutes to process.

This work, developed in collaboration with [TVG@Oxford](https://torrvision.com/index.html) and [UWaterloo](https://uwaterloo.ca/), has been accepted to [NeurIPS 2025 D&B](https://neurips.cc/). The framework builds upon 🐪 [CAMEL-ai](https://github.com/camel-ai/camel).
""", elem_id="intro-md")
    # Top-right logos (camel, tvg, waterloo) if available
    gr.HTML(_ui_header_logos_html())

    # Note: CAMEL line merged into the Markdown above to keep it on the same line.

    # -------- Input box --------
    with gr.Row():
        # ========== LEFT: INPUT ==========
        with gr.Column(scale=1):
            with gr.Accordion("Input", open=True):
                arxiv_in = gr.Textbox(label="📘 ArXiv URL (choose one)", placeholder="https://arxiv.org/abs/2505.xxxxx")
                pdf_in   = gr.File(label="📄 Upload PDF (choose one)")
                key_in   = gr.Textbox(label="🔑 OpenAI API Key", placeholder="sk-...", type="password")

                inst_logo_in = gr.File(
                    label="🏷️ Institutional Logo (optional, multiple allowed)",
                    file_count="multiple",
                    file_types=["image"],
                )

                with gr.Row():
                    with gr.Column():
                        conf_logo_in = gr.File(
                            label="🧩 Optional: Conference Logo (defaults to NeurIPS logo)",
                            file_count="single",
                            file_types=["image"],
                        )
                    with gr.Column():
                        _conf_path = _default_conf_logo_path()
                        conf_preview = gr.Image(
                            value=str(_conf_path) if _conf_path else None,
                            label="Default conference logo preview",
                            interactive=False,
                        )

                theme_in = gr.ColorPicker(label="🎨 Theme Color (optional)", value="#5E2E91")
                run_btn = gr.Button("🚀 Run", variant="primary")

        # ========== RIGHT: OUTPUT ==========
        with gr.Column(scale=1):
            with gr.Accordion("Output", open=True):
                # Preview on top
                img_out      = gr.Image(label="🖼️ Poster (Image Preview)", interactive=False)
                # Logs in the middle (keep compact height)
                logs_out     = gr.Textbox(label="🧾 Logs", lines=10, max_lines=20)
                # Downloads at bottom
                pdf_out      = gr.HTML(label="📄 Poster (PDF Preview)", visible=False)
                with gr.Row():
                    pdf_file_out = gr.File(label="📄 Download Poster (PDF)", interactive=False, visible=True)
                    zip_out      = gr.File(label="📦 Download Results (.zip)", interactive=False, visible=True)
                gr.Markdown("The ZIP can be uploaded to Overleaf and compiled with XeLaTeX.")
                overleaf_out = gr.HTML(label="Open in Overleaf")
                # Debug (hidden)
                debug_zip_btn= gr.Button("🐞 Test repo output.zip", variant="secondary", visible=False)
                debug_zip_out= gr.HTML(label="🐞 Real Output Preview", visible=False)
                debug_zip_img= gr.Image(label="🐞 Real Output Image", interactive=False, visible=False)
                debug_zip_pdfpath = gr.Textbox(visible=False)
                debug_last_btn= gr.Button("🐞 Test last pipeline zip", variant="secondary", visible=False)
                debug_last_out= gr.HTML(label="🐞 Last Pipeline Preview", visible=False)
                debug_last_img= gr.Image(label="🐞 Last Output Image", interactive=False, visible=False)
                debug_last_pdfpath = gr.Textbox(visible=False)

    _run_evt = run_btn.click(
        fn=run_pipeline,
        inputs=[arxiv_in, pdf_in, key_in, inst_logo_in, conf_logo_in, theme_in],
        outputs=[logs_out, pdf_out, pdf_file_out, zip_out, overleaf_out],
    )
    _run_evt.then(fn=preview_image_from_pdf, inputs=[pdf_file_out], outputs=[img_out])
    _dz = debug_zip_btn.click(fn=debug_compile_output_zip, inputs=[], outputs=[debug_zip_out, debug_zip_pdfpath])
    _dz.then(fn=preview_image_from_pdf, inputs=[debug_zip_pdfpath], outputs=[debug_zip_img])
    _dl = debug_last_btn.click(fn=debug_compile_last_pipeline_zip, inputs=[], outputs=[debug_last_out, debug_last_pdfpath])
    _dl.then(fn=preview_image_from_pdf, inputs=[debug_last_pdfpath], outputs=[debug_last_img])

if __name__ == "__main__":
    iface.launch(server_name="0.0.0.0", server_port=7860)
